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 + ![Hex.pm License](https://img.shields.io/hexpm/l/corex) ![Hex.pm Version](https://img.shields.io/hexpm/v/corex) [![Coverage Status](https://coveralls.io/repos/github/corex-ui/corex/badge.svg?branch=corex-install)](https://coveralls.io/github/corex-ui/corex?branch=corex-install) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/corex-ui/corex/elixir.yml) ![GitHub branch check runs](https://img.shields.io/github/check-runs/corex-ui/corex/main) -# 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 = { mounted(this: object & HookInterface & AccordionHookState) { const el = this.el; + const self = this as object & HookInterface & AccordionHookState; const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); + + self.lastValue = getBoolean(el, "controlled") + ? (getStringList(el, "value") ?? []) + : (getStringList(el, "defaultValue") ?? []); const accordion = new Accordion(el, { id: el.id, @@ -24,120 +48,220 @@ const AccordionHook: Hook = { : { defaultValue: getStringList(el, "defaultValue") }), collapsible: getBoolean(el, "collapsible"), multiple: getBoolean(el, "multiple"), - orientation: getString(el, "orientation", ["horizontal", "vertical"]), + orientation: getString(el, "orientation"), dir: getDir(el), onValueChange: (details: ValueChangeDetails) => { - const eventName = getString(el, "onValueChange"); - if (eventName && canPushEvent(this.liveSocket)) { - pushEvent(eventName, { - id: el.id, - value: details.value ?? null, - }); - } + const next = details.value ?? []; + const previousValue = self.lastValue ?? []; + const { added, removed } = diffStringValues(next, previousValue); + self.lastValue = next; - const eventNameClient = getString(el, "onValueChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { - id: el.id, - value: details.value ?? null, - }, - }) - ); - } - }, + const payload: AccordionChangedDetail = { + id: el.id, + value: next, + previousValue, + added, + removed, + }; - onFocusChange: (details: FocusChangeDetails) => { - const eventName = getString(el, "onFocusChange"); - if (eventName && canPushEvent(this.liveSocket)) { - pushEvent(eventName, { - id: el.id, - value: details.value ?? null, + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: payload as unknown as Record, + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); + if (el.dataset.animation === "js" && !getBoolean(el, "controlled")) { + runOpenStateTransitionsHeight({ + rootEl: el, + selector: '[data-scope="accordion"][data-part="item-content"]', + opts: readHeightAnimationOptions(el), + isOpen: (contentEl) => { + const itemEl = contentEl.closest( + '[data-scope="accordion"][data-part="item"]' + ); + const value = itemEl?.dataset.value; + return !!value && next.includes(value); + }, }); } + }, - const eventNameClient = getString(el, "onFocusChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { - id: el.id, - value: details.value ?? null, - }, - }) - ); - } + onFocusChange: (details: FocusChangeDetails) => { + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, value: details.value ?? null } as Record, + serverEventName: getString(el, "onFocusChange"), + clientEventName: getString(el, "onFocusChangeClient"), + }); }, - }); + } as Props); accordion.init(); this.accordion = accordion; - this.onSetValue = (event: Event) => { - const { value } = (event as CustomEvent<{ value: string[] }>).detail; - accordion.api.setValue(value); + if (el.dataset.animation === "js") { + const opts = readHeightAnimationOptions(el); + prepareInitialHeightState(el, '[data-scope="accordion"][data-part="item-content"]', opts); + } + + const emitValue = (respondTo: RespondTo) => { + const value = accordion.api.value; + emitResponse({ + respondTo, + canPushServer: canPush(), + pushEvent, + serverEventName: "accordion_value_response", + serverPayload: { id: el.id, value } as Record, + el, + domEventName: "accordion-value", + domDetail: { id: el.id, value } as Record, + }); }; - el.addEventListener("phx:accordion:set-value", this.onSetValue); - this.handlers = []; + const emitFocusedValue = (respondTo: RespondTo) => { + const value = accordion.api.focusedValue; + emitResponse({ + respondTo, + canPushServer: canPush(), + pushEvent, + serverEventName: "accordion_focused_response", + serverPayload: { id: el.id, value } as Record, + el, + domEventName: "accordion-focused", + domDetail: { id: el.id, value } as Record, + }); + }; - this.handlers.push( - this.handleEvent("accordion_set_value", (payload: { id?: string; value: string[] }) => { - const targetId = payload.id; - if (targetId) { - const matches = el.id === targetId || el.id === `accordion:${targetId}`; - if (!matches) return; - } - accordion.api.setValue(payload.value); - }) - ); + const emitItemState = (itemValue: string, disabled: boolean, respondTo: RespondTo) => { + const props: ItemProps = { value: itemValue, disabled }; + const state = accordion.api.getItemState(props); + emitResponse({ + respondTo, + canPushServer: canPush(), + pushEvent, + serverEventName: "accordion_item_state_response", + serverPayload: { + id: el.id, + value: itemValue, + state: { + expanded: state.expanded, + focused: state.focused, + disabled: state.disabled, + }, + } as Record, + el, + domEventName: "accordion-item-state", + domDetail: { id: el.id, value: itemValue, state } as Record, + }); + }; - this.handlers.push( - this.handleEvent("accordion_value", () => { - this.pushEvent("accordion_value_response", { - value: accordion.api.value, - }); - }) + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:accordion:set-value", (event) => { + accordion.api.setValue(event.detail.value); + }); + + domRegistry.add("corex:accordion:value", (event) => { + emitValue(parseRespondTo(event.detail)); + }); + + domRegistry.add("corex:accordion:focused", (event) => { + emitFocusedValue(parseRespondTo(event.detail)); + }); + + domRegistry.add>( + "corex:accordion:item-state", + (event) => { + const d = event.detail; + const v = d?.value; + if (typeof v !== "string" || v === "") return; + emitItemState(v, d?.disabled === true, parseRespondTo(d)); + } ); - this.handlers.push( - this.handleEvent("accordion_focused_value", () => { - this.pushEvent("accordion_focused_value_response", { - value: accordion.api.focusedValue, - }); - }) + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("accordion_set_value", (payload: { id?: string; value: string[] }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + accordion.api.setValue(payload.value); + }); + + registry.add("accordion_value", (payload: { id?: string; respond_to?: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + emitValue(parseRespondTo(payload)); + }); + + registry.add("accordion_focused", (payload: { id?: string; respond_to?: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + emitFocusedValue(parseRespondTo(payload)); + }); + + registry.add( + "accordion_item_state", + (payload: { id?: string; value?: string; disabled?: boolean; respond_to?: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (typeof payload?.value !== "string" || payload.value === "") return; + emitItemState(payload.value, payload.disabled === true, parseRespondTo(payload)); + } ); }, + beforeUpdate(this: object & HookInterface & AccordionHookState) { + if (getBoolean(this.el, "controlled")) { + this.previousValue = getStringList(this.el, "value") ?? []; + } + }, + updated(this: object & HookInterface & AccordionHookState) { const controlled = getBoolean(this.el, "controlled"); + if (controlled) { + const nextValue = getStringList(this.el, "value") ?? []; + const prevValue = this.previousValue ?? this.lastValue ?? []; + this.previousValue = undefined; + this.lastValue = nextValue; + if (this.el.dataset.animation === "js") { + runOpenStateTransitionsHeight({ + rootEl: this.el, + selector: '[data-scope="accordion"][data-part="item-content"]', + opts: readHeightAnimationOptions(this.el), + wasOpen: (contentEl) => { + const itemEl = contentEl.closest( + '[data-scope="accordion"][data-part="item"]' + ); + const value = itemEl?.dataset.value; + return !!value && prevValue.includes(value); + }, + isOpen: (contentEl) => { + const itemEl = contentEl.closest( + '[data-scope="accordion"][data-part="item"]' + ); + const value = itemEl?.dataset.value; + return !!value && nextValue.includes(value); + }, + }); + } + } + this.accordion?.updateProps({ id: this.el.id, ...(controlled ? { value: getStringList(this.el, "value") } - : { - defaultValue: this.accordion?.api?.value ?? getStringList(this.el, "defaultValue"), - }), + : { defaultValue: getStringList(this.el, "defaultValue") }), collapsible: getBoolean(this.el, "collapsible"), multiple: getBoolean(this.el, "multiple"), - orientation: getString(this.el, "orientation", ["horizontal", "vertical"]), + orientation: getString(this.el, "orientation"), dir: getDir(this.el), } as Props); }, destroyed(this: object & HookInterface & AccordionHookState) { - if (this.onSetValue) { - this.el.removeEventListener("phx:accordion:set-value", this.onSetValue); - } - - if (this.handlers) { - for (const handler of this.handlers) { - this.removeHandleEvent(handler); - } - } - + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.accordion?.destroy(); }, }; diff --git a/assets/hooks/angle-slider.ts b/assets/hooks/angle-slider.ts index d311484a..57c320f0 100644 --- a/assets/hooks/angle-slider.ts +++ b/assets/hooks/angle-slider.ts @@ -1,178 +1,160 @@ 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 { AngleSlider } from "../components/angle-slider"; import type { Props, ValueChangeDetails } from "@zag-js/angle-slider"; -import { getString, getBoolean, getNumber } from "../lib/util"; +import { getString, getBoolean, getDir, canPushEvent } from "../lib/util"; +import { readNumberControlledZagProps } from "../lib/read-props"; +import { + parseRespondTo, + emitResponse, + idMatches, + readPayloadId, + notifyChange, + type RespondTo, +} from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; type AngleSliderHookState = { angleSlider?: AngleSlider; - handlers?: Array; - onSetValue?: (event: Event) => void; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; }; +function valueChangePayload(el: HTMLElement, details: ValueChangeDetails): Record { + return { + id: el.id, + value: details.value, + valueAsDegree: details.valueAsDegree, + }; +} + +function queueFormBubblingInputForPhoenix( + el: HTMLElement, + getZag: () => InstanceType +): void { + queueMicrotask(() => { + const zag = getZag(); + const input = el.querySelector( + '[data-scope="angle-slider"][data-part="hidden-input"]' + ); + if (!input) return; + const v = zag.api.value; + if (String(input.value) !== String(v)) { + input.value = String(v); + } + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + const AngleSliderHook: Hook = { mounted(this: object & HookInterface & AngleSliderHookState) { const el = this.el; - const value = getNumber(el, "value"); - const defaultValue = getNumber(el, "defaultValue"); - const controlled = getBoolean(el, "controlled"); + const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const zag = new AngleSlider(el, { id: el.id, - ...(controlled && value !== undefined ? { value } : { defaultValue: defaultValue ?? 0 }), - step: getNumber(el, "step") ?? 1, + ...readNumberControlledZagProps(el), disabled: getBoolean(el, "disabled"), readOnly: getBoolean(el, "readOnly"), invalid: getBoolean(el, "invalid"), name: getString(el, "name"), - dir: getString<"ltr" | "rtl">(el, "dir", ["ltr", "rtl"]), + dir: getDir(el), "aria-label": getString(el, "aria-label"), "aria-labelledby": getString(el, "aria-labelledby"), onValueChange: (details: ValueChangeDetails) => { - // if (skipNextOnValueChange) { - // skipNextOnValueChange = false; - // return; - // } - // if (controlled) { - // skipNextOnValueChange = true; - // zag.api.setValue(details.value); - // } else { - // const hiddenInput = el.querySelector( - // '[data-scope="angle-slider"][data-part="hidden-input"]' - // ); - // if (hiddenInput) { - // hiddenInput.value = String(details.value); - // hiddenInput.dispatchEvent(new Event("input", { bubbles: true })); - // hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); - // } - // } - // if (skipNextOnValueChange) { - // skipNextOnValueChange = false; - // return; - // } - // if (controlled) { - // skipNextOnValueChange = true; - // zag.api.setValue(details.value); - // } - // const hiddenInput = el.querySelector( - // '[data-scope="angle-slider"][data-part="hidden-input"]' - // ); - // const hiddenInput = el.querySelector( - // '[data-scope="angle-slider"][data-part="hidden-input"]' - // ); - // if (hiddenInput) { - // hiddenInput.value = String(details.value); - // hiddenInput.dispatchEvent(new Event("input", { bubbles: true })); - // hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); - // } - const eventName = getString(el, "onValueChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { - value: details.value, - valueAsDegree: details.valueAsDegree, - id: el.id, - }); - } - const eventNameClient = getString(el, "onValueChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: valueChangePayload(el, details), + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); }, onValueChangeEnd: (details: ValueChangeDetails) => { - // if (controlled) { - // const hiddenInput = el.querySelector( - // '[data-scope="angle-slider"][data-part="hidden-input"]' - // ); - // if (hiddenInput) { - // hiddenInput.value = String(details.value); - // hiddenInput.dispatchEvent(new Event("input", { bubbles: true })); - // hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); - // } - // } - const eventName = getString(el, "onValueChangeEnd"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { - value: details.value, - valueAsDegree: details.valueAsDegree, - id: el.id, - }); - } - const eventNameClient = getString(el, "onValueChangeEndClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: valueChangePayload(el, details), + serverEventName: getString(el, "onValueChangeEnd"), + clientEventName: getString(el, "onValueChangeEndClient"), + }); + queueFormBubblingInputForPhoenix(el, () => zag); }, } as Props); zag.init(); this.angleSlider = zag; - this.handlers = []; - this.onSetValue = (event: Event) => { - const { value } = (event as CustomEvent<{ value: number }>).detail; - zag.api.setValue(value); - }; - el.addEventListener("phx:angle-slider:set-value", this.onSetValue); - - this.handlers.push( - this.handleEvent( - "angle_slider_set_value", - (payload: { angle_slider_id?: string; value: number }) => { - const targetId = payload.angle_slider_id; - if (targetId) { - const matches = el.id === targetId || el.id === `angle-slider:${targetId}`; - if (!matches) return; - } - zag.api.setValue(payload.value); - } - ) - ); - - this.handlers.push( - this.handleEvent("angle_slider_value", () => { - this.pushEvent("angle_slider_value_response", { + const emitValue = (respondTo: RespondTo) => { + emitResponse({ + respondTo, + canPushServer: canPush(), + pushEvent, + serverEventName: "angle_slider_value_response", + serverPayload: { + id: el.id, value: zag.api.value, valueAsDegree: zag.api.valueAsDegree, dragging: zag.api.dragging, - }); - }) - ); + } as Record, + el, + domEventName: "angle-slider-value", + domDetail: { + id: el.id, + value: zag.api.value, + valueAsDegree: zag.api.valueAsDegree, + dragging: zag.api.dragging, + } as Record, + }); + }; + + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:angle-slider:set-value", (event) => { + zag.api.setValue(event.detail.value); + queueFormBubblingInputForPhoenix(el, () => zag); + }); + + domRegistry.add("corex:angle-slider:value", (event) => { + emitValue(parseRespondTo(event.detail)); + }); + + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("angle_slider_set_value", (payload: { id?: string; value: number }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + zag.api.setValue(payload.value); + queueFormBubblingInputForPhoenix(el, () => zag); + }); + + registry.add("angle_slider_value", (payload: { id?: string; respond_to?: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + emitValue(parseRespondTo(payload)); + }); }, updated(this: object & HookInterface & AngleSliderHookState) { - const value = getNumber(this.el, "value"); - const defaultValue = getNumber(this.el, "defaultValue"); - - const controlled = getBoolean(this.el, "controlled"); this.angleSlider?.updateProps({ id: this.el.id, - ...(controlled && value !== undefined ? { value } : { defaultValue: defaultValue ?? 0 }), - step: getNumber(this.el, "step") ?? 1, + ...readNumberControlledZagProps(this.el), disabled: getBoolean(this.el, "disabled"), readOnly: getBoolean(this.el, "readOnly"), invalid: getBoolean(this.el, "invalid"), name: getString(this.el, "name"), - dir: getString<"ltr" | "rtl">(this.el, "dir", ["ltr", "rtl"]), + dir: getDir(this.el), } as Partial); }, destroyed(this: object & HookInterface & AngleSliderHookState) { - if (this.onSetValue) { - this.el.removeEventListener("phx:angle-slider:set-value", this.onSetValue); - } - if (this.handlers) { - for (const h of this.handlers) this.removeHandleEvent(h); - } + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.angleSlider?.destroy(); }, }; diff --git a/assets/hooks/avatar.ts b/assets/hooks/avatar.ts index 87504639..360805e3 100644 --- a/assets/hooks/avatar.ts +++ b/assets/hooks/avatar.ts @@ -1,56 +1,123 @@ 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 { Avatar } from "../components/avatar"; import type { Props, StatusChangeDetails } from "@zag-js/avatar"; -import { getString } from "../lib/util"; +import { getString, canPushEvent } from "../lib/util"; +import type { Direction } from "@zag-js/types"; +import { + parseRespondTo, + emitResponse, + idMatches, + readPayloadId, + notifyChange, + type RespondTo, +} from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; type AvatarHookState = { avatar?: Avatar; - handlers?: Array; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; + lastSrc?: string; }; -const AvatarHook: Hook = { +function statusPayload(el: HTMLElement, details: StatusChangeDetails): Record { + return { id: el.id, status: details.status }; +} + +const AvatarHook: Hook & AvatarHookState, HTMLElement> = { mounted(this: object & HookInterface & AvatarHookState) { const el = this.el; - const src = getString(el, "src"); + const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); + const initialSrc = getString(el, "src"); + const zag = new Avatar(el, { id: el.id, + dir: getString(el, "dir"), onStatusChange: (details: StatusChangeDetails) => { - const eventName = getString(el, "onStatusChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { status: details.status, id: el.id }); - } - const clientName = getString(el, "onStatusChangeClient"); - if (clientName) { - el.dispatchEvent( - new CustomEvent(clientName, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); - } + const flat = statusPayload(el, details); + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: flat, + serverEventName: getString(el, "onStatusChange"), + clientEventName: getString(el, "onStatusChangeClient"), + }); }, - ...(src !== undefined ? {} : {}), } as Props); zag.init(); this.avatar = zag; - if (src !== undefined) { - zag.api.setSrc(src); - } - this.handlers = []; + this.lastSrc = initialSrc; + + const emitLoaded = (respondTo: RespondTo) => { + const loaded = zag.api.loaded; + emitResponse({ + respondTo, + canPushServer: canPush(), + pushEvent, + serverEventName: "avatar_loaded_response", + serverPayload: { id: el.id, loaded } as Record, + el, + domEventName: "avatar-loaded", + domDetail: { id: el.id, loaded } as Record, + }); + }; + + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:avatar:set-src", (event) => { + const next = event.detail?.src; + if (typeof next !== "string") return; + zag.api.setSrc(next); + this.lastSrc = next; + el.dataset.src = next; + }); + + domRegistry.add("corex:avatar:loaded", (event) => { + emitLoaded(parseRespondTo(event.detail)); + }); + + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("avatar_set_src", (payload: { id?: string; src: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + zag.api.setSrc(payload.src); + this.lastSrc = payload.src; + el.dataset.src = payload.src; + }); + + registry.add("avatar_loaded", (payload: { id?: string; respond_to?: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + emitLoaded(parseRespondTo(payload)); + }); }, updated(this: object & HookInterface & AvatarHookState) { const src = getString(this.el, "src"); - if (src !== undefined && this.avatar) { + const dir = getString(this.el, "dir"); + if (this.avatar) { + this.avatar.updateProps({ + ...(dir !== undefined ? { dir } : {}), + } as Partial); + } + if (this.avatar && src !== undefined && src !== this.lastSrc) { this.avatar.api.setSrc(src); + this.lastSrc = src; + } + if (this.avatar && src === undefined && this.lastSrc !== undefined) { + this.avatar.api.setSrc(""); + this.lastSrc = undefined; } }, destroyed(this: object & HookInterface & AvatarHookState) { - if (this.handlers) { - for (const h of this.handlers) this.removeHandleEvent(h); - } + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.avatar?.destroy(); }, }; diff --git a/assets/hooks/carousel.ts b/assets/hooks/carousel.ts index 8d62eefd..0e6fa65c 100644 --- a/assets/hooks/carousel.ts +++ b/assets/hooks/carousel.ts @@ -1,19 +1,31 @@ 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 { Carousel } from "../components/carousel"; import type { Props, PageChangeDetails } from "@zag-js/carousel"; -import { getString, getBoolean, getNumber, getDir } from "../lib/util"; +import { getString, getBoolean, getNumber, getDir, canPushEvent } from "../lib/util"; +import { idMatches, notifyChange, readPayloadId } from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; type CarouselHookState = { carousel?: Carousel; - handlers?: Array; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; }; +function readInstant(detail: unknown): boolean { + if (detail && typeof detail === "object" && "instant" in detail) { + const v = (detail as { instant?: unknown }).instant; + return v === true || v === "true"; + } + return false; +} + const CarouselHook: Hook = { mounted(this: object & HookInterface & CarouselHookState) { const el = this.el; - const page = getNumber(el, "page"); - const defaultPage = getNumber(el, "defaultPage"); + const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const controlled = getBoolean(el, "controlled"); const slideCount = getNumber(el, "slideCount"); if (slideCount == null || slideCount < 1) { @@ -22,89 +34,108 @@ const CarouselHook: Hook = { const zag = new Carousel(el, { id: el.id, slideCount, - ...(controlled && page !== undefined ? { page } : { defaultPage: defaultPage ?? 0 }), + ...(controlled + ? { page: getNumber(el, "page") } + : { defaultPage: getNumber(el, "defaultPage") }), dir: getDir(el), - orientation: getString<"horizontal" | "vertical">(el, "orientation", [ - "horizontal", - "vertical", - ]), - slidesPerPage: getNumber(el, "slidesPerPage") ?? 1, + orientation: getString<"horizontal" | "vertical">(el, "orientation"), + slidesPerPage: getNumber(el, "slidesPerPage"), slidesPerMove: getString(el, "slidesPerMove") === "auto" ? "auto" : getNumber(el, "slidesPerMove"), loop: getBoolean(el, "loop"), - autoplay: getBoolean(el, "autoplay") - ? { delay: getNumber(el, "autoplayDelay") ?? 4000 } - : false, + autoplay: getBoolean(el, "autoplay") ? { delay: getNumber(el, "autoplayDelay") } : false, allowMouseDrag: getBoolean(el, "allowMouseDrag"), - spacing: getString(el, "spacing") ?? "0px", + spacing: getString(el, "spacing"), padding: getString(el, "padding"), - inViewThreshold: getNumber(el, "inViewThreshold") ?? 0.6, - snapType: getString<"proximity" | "mandatory">(el, "snapType", ["proximity", "mandatory"]), + inViewThreshold: getNumber(el, "inViewThreshold"), + snapType: getString<"proximity" | "mandatory">(el, "snapType"), autoSize: getBoolean(el, "autoSize"), onPageChange: (details: PageChangeDetails) => { - const eventName = getString(el, "onPageChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { + id: el.id, page: details.page, pageSnapPoint: details.pageSnapPoint, - id: el.id, - }); - } - const clientName = getString(el, "onPageChangeClient"); - if (clientName) { - el.dispatchEvent( - new CustomEvent(clientName, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); - } + }, + serverEventName: getString(el, "onPageChange"), + clientEventName: getString(el, "onPageChangeClient"), + }); }, } as Props); zag.init(); this.carousel = zag; - this.handlers = []; + + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + domRegistry.add("corex:carousel:play", () => { + zag.api.play(); + }); + domRegistry.add("corex:carousel:pause", () => { + zag.api.pause(); + }); + domRegistry.add("corex:carousel:scroll-next", (event) => { + zag.api.scrollNext(readInstant(event.detail)); + }); + domRegistry.add("corex:carousel:scroll-prev", (event) => { + zag.api.scrollPrev(readInstant(event.detail)); + }); + + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + registry.add("carousel_play", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + zag.api.play(); + }); + registry.add("carousel_pause", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + zag.api.pause(); + }); + registry.add("carousel_scroll_next", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + zag.api.scrollNext(readInstant(payload)); + }); + registry.add("carousel_scroll_prev", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + zag.api.scrollPrev(readInstant(payload)); + }); }, updated(this: object & HookInterface & CarouselHookState) { const slideCount = getNumber(this.el, "slideCount"); if (slideCount == null || slideCount < 1) return; - const page = getNumber(this.el, "page"); const controlled = getBoolean(this.el, "controlled"); this.carousel?.updateProps({ id: this.el.id, slideCount, - ...(controlled && page !== undefined ? { page } : {}), + ...(controlled + ? { page: getNumber(this.el, "page") } + : { defaultPage: getNumber(this.el, "defaultPage") }), dir: getDir(this.el), - orientation: getString<"horizontal" | "vertical">(this.el, "orientation", [ - "horizontal", - "vertical", - ]), - slidesPerPage: getNumber(this.el, "slidesPerPage") ?? 1, + orientation: getString<"horizontal" | "vertical">(this.el, "orientation"), + slidesPerPage: getNumber(this.el, "slidesPerPage"), slidesPerMove: getString(this.el, "slidesPerMove") === "auto" ? "auto" : getNumber(this.el, "slidesPerMove"), loop: getBoolean(this.el, "loop"), autoplay: getBoolean(this.el, "autoplay") - ? { delay: getNumber(this.el, "autoplayDelay") ?? 4000 } + ? { delay: getNumber(this.el, "autoplayDelay") } : false, allowMouseDrag: getBoolean(this.el, "allowMouseDrag"), - spacing: getString(this.el, "spacing") ?? "0px", + spacing: getString(this.el, "spacing"), padding: getString(this.el, "padding"), - inViewThreshold: getNumber(this.el, "inViewThreshold") ?? 0.6, - snapType: getString<"proximity" | "mandatory">(this.el, "snapType", [ - "proximity", - "mandatory", - ]), + inViewThreshold: getNumber(this.el, "inViewThreshold"), + snapType: getString<"proximity" | "mandatory">(this.el, "snapType"), autoSize: getBoolean(this.el, "autoSize"), } as Partial); }, destroyed(this: object & HookInterface & CarouselHookState) { - if (this.handlers) { - for (const h of this.handlers) this.removeHandleEvent(h); - } + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.carousel?.destroy(); }, }; diff --git a/assets/hooks/checkbox.ts b/assets/hooks/checkbox.ts index 64c921d1..a9dbd1f7 100644 --- a/assets/hooks/checkbox.ts +++ b/assets/hooks/checkbox.ts @@ -1,27 +1,39 @@ 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 { Checkbox } from "../components/checkbox"; import type { CheckedChangeDetails } from "@zag-js/checkbox"; - -import { getString, getBoolean, getDir, canPushEvent } from "../lib/util"; +import { getString, getBoolean, getDir, getCheckedState, canPushEvent } from "../lib/util"; +import { idMatches, notifyChange, readPayloadId, readPayloadChecked } from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; type CheckboxHookState = { checkbox?: Checkbox; - handlers?: Array; - onSetChecked?: (event: Event) => void; - onToggleChecked?: () => void; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; }; +function checkedChangePayload( + el: HTMLElement, + details: CheckedChangeDetails +): Record { + return { + id: el.id, + checked: details.checked, + }; +} + const CheckboxHook: Hook = { mounted(this: object & HookInterface & CheckboxHookState) { const el = this.el; const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const zagCheckbox = new Checkbox(el, { id: el.id, ...(getBoolean(el, "controlled") - ? { checked: getBoolean(el, "checked") } - : { defaultChecked: getBoolean(el, "defaultChecked") }), + ? { checked: getCheckedState(el, "checked") } + : { defaultChecked: getCheckedState(el, "defaultChecked") }), disabled: getBoolean(el, "disabled"), name: getString(el, "name"), form: getString(el, "form"), @@ -32,92 +44,80 @@ const CheckboxHook: Hook = { readOnly: getBoolean(el, "readOnly"), onCheckedChange: (details: CheckedChangeDetails) => { - const eventName = getString(el, "onCheckedChange"); - if (eventName && canPushEvent(this.liveSocket)) { - pushEvent(eventName, { - checked: details.checked, - id: el.id, - }); - } - - const eventNameClient = getString(el, "onCheckedChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { - id: el.id, - checked: details.checked, - }, - }) - ); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: checkedChangePayload(el, details), + serverEventName: getString(el, "onCheckedChange"), + clientEventName: getString(el, "onCheckedChangeClient"), + }); }, }); zagCheckbox.init(); this.checkbox = zagCheckbox; - this.onSetChecked = (event: Event) => { - const { checked } = (event as CustomEvent<{ checked: boolean }>).detail; + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:checkbox:set-checked", (event) => { + const { checked } = event.detail; zagCheckbox.api.setChecked(checked); - }; - el.addEventListener("phx:checkbox:set-checked", this.onSetChecked); + }); - this.onToggleChecked = () => { + domRegistry.add("corex:checkbox:toggle-checked", () => { zagCheckbox.api.toggleChecked(); - }; - el.addEventListener("phx:checkbox:toggle-checked", this.onToggleChecked); - - this.handlers = []; - - this.handlers.push( - this.handleEvent("checkbox_set_checked", (payload: { id?: string; checked: boolean }) => { - const targetId = payload.id; - if (targetId && targetId !== el.id) return; - zagCheckbox.api.setChecked(payload.checked); - }) - ); - - this.handlers.push( - this.handleEvent("checkbox_toggle_checked", (payload: { id?: string }) => { - const targetId = payload.id; - if (targetId && targetId !== el.id) return; - zagCheckbox.api.toggleChecked(); - }) - ); - - this.handlers.push( - this.handleEvent("checkbox_checked", () => { - this.pushEvent("checkbox_checked_response", { - value: zagCheckbox.api.checked, - }); - }) - ); + }); - this.handlers.push( - this.handleEvent("checkbox_focused", () => { - this.pushEvent("checkbox_focused_response", { - value: zagCheckbox.api.focused, - }); - }) - ); + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; - this.handlers.push( - this.handleEvent("checkbox_disabled", () => { - this.pushEvent("checkbox_disabled_response", { - value: zagCheckbox.api.disabled, - }); - }) - ); + registry.add("checkbox_set_checked", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + const checked = readPayloadChecked(payload); + if (typeof checked === "boolean") zagCheckbox.api.setChecked(checked); + }); + + registry.add("checkbox_toggle_checked", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + zagCheckbox.api.toggleChecked(); + }); + + registry.add("checkbox_checked", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (!canPush()) return; + this.pushEvent("checkbox_checked_response", { + id: el.id, + value: zagCheckbox.api.checked, + }); + }); + + registry.add("checkbox_focused", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (!canPush()) return; + this.pushEvent("checkbox_focused_response", { + id: el.id, + value: zagCheckbox.api.focused, + }); + }); + + registry.add("checkbox_disabled", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (!canPush()) return; + this.pushEvent("checkbox_disabled_response", { + id: el.id, + value: zagCheckbox.api.disabled, + }); + }); }, updated(this: object & HookInterface & CheckboxHookState) { this.checkbox?.updateProps({ id: this.el.id, ...(getBoolean(this.el, "controlled") - ? { checked: getBoolean(this.el, "checked") } - : { defaultChecked: getBoolean(this.el, "defaultChecked") }), + ? { checked: getCheckedState(this.el, "checked") } + : { defaultChecked: getCheckedState(this.el, "defaultChecked") }), disabled: getBoolean(this.el, "disabled"), name: getString(this.el, "name"), form: getString(this.el, "form"), @@ -126,25 +126,12 @@ const CheckboxHook: Hook = { invalid: getBoolean(this.el, "invalid"), required: getBoolean(this.el, "required"), readOnly: getBoolean(this.el, "readOnly"), - label: getString(this.el, "label"), }); }, destroyed(this: object & HookInterface & CheckboxHookState) { - if (this.onSetChecked) { - this.el.removeEventListener("phx:checkbox:set-checked", this.onSetChecked); - } - - if (this.onToggleChecked) { - this.el.removeEventListener("phx:checkbox:toggle-checked", this.onToggleChecked); - } - - if (this.handlers) { - for (const handler of this.handlers) { - this.removeHandleEvent(handler); - } - } - + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.checkbox?.destroy(); }, }; diff --git a/assets/hooks/clipboard.ts b/assets/hooks/clipboard.ts index b0360f0d..f59f7728 100644 --- a/assets/hooks/clipboard.ts +++ b/assets/hooks/clipboard.ts @@ -1,129 +1,87 @@ 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 { Clipboard } from "../components/clipboard"; -import type { ValueChangeDetails } from "@zag-js/clipboard"; -import type { Direction } from "@zag-js/types"; -import { getString, getNumber, getBoolean } from "../lib/util"; +import { getString, getNumber, canPushEvent } from "../lib/util"; +import { idMatches, notifyChange, readPayloadId } from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; type ClipboardHookState = { clipboard?: Clipboard; - handlers?: Array; - onCopy?: (event: Event) => void; - onSetValue?: (event: Event) => void; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; }; const ClipboardHook: Hook = { mounted(this: object & HookInterface & ClipboardHookState) { const el = this.el; const pushEvent = this.pushEvent.bind(this); - const liveSocket = this.liveSocket; + const canPush = () => canPushEvent(this.liveSocket); const clipboard = new Clipboard(el, { id: el.id, timeout: getNumber(el, "timeout"), - ...(getBoolean(el, "controlled") - ? { value: getString(el, "value") } - : { defaultValue: getString(el, "defaultValue") }), - - onValueChange: (details: ValueChangeDetails) => { - const eventName = getString(el, "onValueChange"); - if (eventName && liveSocket.main.isConnected()) { - pushEvent(eventName, { - id: el.id, - value: details.value ?? null, - }); - } - }, + defaultValue: getString(el, "defaultValue"), + onStatusChange: (details) => { - const eventName = getString(el, "onStatusChange"); - if (eventName && liveSocket.main.isConnected()) { - pushEvent(eventName, { - id: el.id, - copied: details.copied, - }); - } - const eventNameClient = getString(el, "onStatusChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - }) - ); - } + if (details?.copied !== true) return; + const value = clipboard.api.value ?? getString(el, "defaultValue"); + + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, value }, + serverEventName: getString(el, "onCopy"), + clientEventName: getString(el, "onCopyClient"), + }); }, }); clipboard.init(); this.clipboard = clipboard; - this.onCopy = () => { + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add("corex:clipboard:copy", () => { clipboard.api.copy(); - }; - el.addEventListener("phx:clipboard:copy", this.onCopy); - - this.onSetValue = (event: Event) => { - const { value } = (event as CustomEvent<{ value: string }>).detail; - clipboard.api.setValue(value); - }; - el.addEventListener("phx:clipboard:set-value", this.onSetValue); - - this.handlers = []; - - this.handlers.push( - this.handleEvent("clipboard_copy", (payload: { clipboard_id?: string }) => { - const targetId = payload.clipboard_id; - if (targetId && targetId !== el.id) return; - clipboard.api.copy(); - }) - ); - - this.handlers.push( - this.handleEvent( - "clipboard_set_value", - (payload: { clipboard_id?: string; value: string }) => { - const targetId = payload.clipboard_id; - if (targetId && targetId !== el.id) return; - clipboard.api.setValue(payload.value); - } - ) - ); - - this.handlers.push( - this.handleEvent("clipboard_copied", () => { - this.pushEvent("clipboard_copied_response", { - value: clipboard.api.copied, - }); - }) - ); + }); + + domRegistry.add>("corex:clipboard:set-value", (event) => { + const v = event.detail?.value; + if (typeof v === "string") clipboard.api.setValue(v); + }); + + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("clipboard_copy", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + clipboard.api.copy(); + }); + + registry.add("clipboard_set_value", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (!payload || typeof payload !== "object") return; + const o = payload as Record; + const v = o.value ?? o["value"]; + if (typeof v === "string") clipboard.api.setValue(v); + }); }, updated(this: object & HookInterface & ClipboardHookState) { this.clipboard?.updateProps({ id: this.el.id, timeout: getNumber(this.el, "timeout"), - ...(getBoolean(this.el, "controlled") - ? { value: getString(this.el, "value") } - : { defaultValue: getString(this.el, "value") }), - dir: getString(this.el, "dir", ["ltr", "rtl"]), + defaultValue: getString(this.el, "defaultValue"), }); }, destroyed(this: object & HookInterface & ClipboardHookState) { - if (this.onCopy) { - this.el.removeEventListener("phx:clipboard:copy", this.onCopy); - } - - if (this.onSetValue) { - this.el.removeEventListener("phx:clipboard:set-value", this.onSetValue); - } - - if (this.handlers) { - for (const handler of this.handlers) { - this.removeHandleEvent(handler); - } - } - + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.clipboard?.destroy(); }, }; diff --git a/assets/hooks/code.ts b/assets/hooks/code.ts new file mode 100644 index 00000000..9d622090 --- /dev/null +++ b/assets/hooks/code.ts @@ -0,0 +1,35 @@ +import type { Hook } from "phoenix_live_view"; +import type { HookInterface } from "phoenix_live_view/assets/js/types/view_hook"; + +type CodeHookState = { + _scrollTop?: number; + _scrollLeft?: number; +}; + +const CodeHook: Hook & CodeHookState, HTMLElement> = { + mounted(this: object & HookInterface & CodeHookState) { + if (this.el.tagName === "PRE") { + this._scrollTop = this.el.scrollTop; + this._scrollLeft = this.el.scrollLeft; + } + }, + + beforeUpdate(this: object & HookInterface & CodeHookState) { + if (this.el.tagName === "PRE") { + this._scrollTop = this.el.scrollTop; + this._scrollLeft = this.el.scrollLeft; + } + }, + + updated(this: object & HookInterface & CodeHookState) { + if (this.el.tagName !== "PRE") return; + const st = this._scrollTop ?? 0; + const sl = this._scrollLeft ?? 0; + requestAnimationFrame(() => { + this.el.scrollTop = st; + this.el.scrollLeft = sl; + }); + }, +}; + +export { CodeHook as Code }; diff --git a/assets/hooks/collapsible.ts b/assets/hooks/collapsible.ts index c9774067..f73fd1ed 100644 --- a/assets/hooks/collapsible.ts +++ b/assets/hooks/collapsible.ts @@ -1,21 +1,38 @@ 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 { Collapsible } from "../components/collapsible"; import type { OpenChangeDetails } from "@zag-js/collapsible"; -import type { Direction } from "@zag-js/types"; -import { getString, getBoolean } from "../lib/util"; +import { getBoolean, getDir, getString, canPushEvent } from "../lib/util"; +import { + emitResponse, + idMatches, + notifyChange, + parseRespondTo, + readPayloadId, + type RespondTo, +} from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; type CollapsibleHookState = { collapsible?: Collapsible; - handlers?: Array; - onSetOpen?: (event: Event) => void; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; }; +function openChangePayload(el: HTMLElement, details: OpenChangeDetails): Record { + return { + id: el.id, + open: details.open, + }; +} + const CollapsibleHook: Hook = { mounted(this: object & HookInterface & CollapsibleHookState) { const el = this.el; const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const collapsible = new Collapsible(el, { id: el.id, @@ -23,60 +40,67 @@ const CollapsibleHook: Hook = { ? { open: getBoolean(el, "open") } : { defaultOpen: getBoolean(el, "defaultOpen") }), disabled: getBoolean(el, "disabled"), - dir: getString(el, "dir", ["ltr", "rtl"]), + dir: getDir(el), onOpenChange: (details: OpenChangeDetails) => { - const eventName = getString(el, "onOpenChange"); - if (eventName && this.liveSocket.main.isConnected()) { - pushEvent(eventName, { - id: el.id, - open: details.open, - }); - } - - const eventNameClient = getString(el, "onOpenChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { - id: el.id, - open: details.open, - }, - }) - ); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: openChangePayload(el, details), + serverEventName: getString(el, "onOpenChange"), + clientEventName: getString(el, "onOpenChangeClient"), + }); }, }); collapsible.init(); this.collapsible = collapsible; - this.onSetOpen = (event: Event) => { - const { open } = (event as CustomEvent<{ open: boolean }>).detail; - collapsible.api.setOpen(open); + const emitOpen = (respondTo: RespondTo) => { + emitResponse({ + respondTo, + canPushServer: canPush(), + pushEvent, + serverEventName: "collapsible_open_response", + serverPayload: { + id: el.id, + open: collapsible.api.open, + disabled: collapsible.api.disabled, + } as Record, + el, + domEventName: "collapsible-open", + domDetail: { + id: el.id, + open: collapsible.api.open, + disabled: collapsible.api.disabled, + } as Record, + }); }; - el.addEventListener("phx:collapsible:set-open", this.onSetOpen); - - this.handlers = []; - - this.handlers.push( - this.handleEvent( - "collapsible_set_open", - (payload: { collapsible_id?: string; open: boolean }) => { - const targetId = payload.collapsible_id; - if (targetId && targetId !== el.id) return; - collapsible.api.setOpen(payload.open); - } - ) - ); - - this.handlers.push( - this.handleEvent("collapsible_open", () => { - this.pushEvent("collapsible_open_response", { - value: collapsible.api.open, - }); - }) - ); + + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:collapsible:set-open", (event) => { + const { open } = event.detail; + collapsible.api.setOpen(open); + }); + + domRegistry.add("corex:collapsible:open", (event) => { + emitOpen(parseRespondTo(event.detail)); + }); + + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("collapsible_set_open", (payload: { id?: string; open: boolean }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + collapsible.api.setOpen(payload.open); + }); + + registry.add("collapsible_open", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + emitOpen(parseRespondTo(payload)); + }); }, updated(this: object & HookInterface & CollapsibleHookState) { @@ -86,21 +110,13 @@ const CollapsibleHook: Hook = { ? { open: getBoolean(this.el, "open") } : { defaultOpen: getBoolean(this.el, "defaultOpen") }), disabled: getBoolean(this.el, "disabled"), - dir: getString(this.el, "dir", ["ltr", "rtl"]), + dir: getDir(this.el), }); }, destroyed(this: object & HookInterface & CollapsibleHookState) { - if (this.onSetOpen) { - this.el.removeEventListener("phx:collapsible:set-open", this.onSetOpen); - } - - if (this.handlers) { - for (const handler of this.handlers) { - this.removeHandleEvent(handler); - } - } - + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.collapsible?.destroy(); }, }; diff --git a/assets/hooks/color-picker.ts b/assets/hooks/color-picker.ts index c68b11a1..66119f05 100644 --- a/assets/hooks/color-picker.ts +++ b/assets/hooks/color-picker.ts @@ -6,252 +6,175 @@ import type { ValueChangeDetails, OpenChangeDetails, FormatChangeDetails, - ColorFormat, } from "@zag-js/color-picker"; -import { getString, getBoolean, getDir } from "../lib/util"; +import { getString, getBoolean, getDir, canPushEvent } from "../lib/util"; +import { readPositioningOptions } from "../lib/positioning"; +import { idMatches, notifyChange, readPayloadId } from "../lib/respond-to"; type ColorPickerHookState = { colorPicker?: ColorPicker; handlers?: Array; - onSetOpen?: (event: Event) => void; onSetValue?: (event: Event) => void; - onSetFormat?: (event: Event) => void; }; -function parsePositioning(val: string | undefined): Props["positioning"] | undefined { - if (!val) return undefined; - try { - return JSON.parse(val) as Props["positioning"]; - } catch { - return undefined; +function syncColorHiddenAndNotify(el: HTMLElement, valueAsString: string | undefined) { + if (valueAsString === undefined) { + return; } + const hidden = el.querySelector( + '[data-scope="color-picker"][data-part="hidden-input"]' + ); + if (hidden) { + hidden.value = valueAsString; + hidden.dispatchEvent(new Event("input", { bubbles: true })); + hidden.dispatchEvent(new Event("change", { bubbles: true })); + } +} + +function readValueProps(el: HTMLElement): Pick { + const defaultVal = getString(el, "defaultValue"); + return { defaultValue: defaultVal ? parse(defaultVal) : undefined }; } const ColorPickerHook: Hook = { mounted(this: object & HookInterface & ColorPickerHookState) { const el = this.el; - const controlled = getBoolean(el, "controlled"); - const defaultVal = getString(el, "defaultValue"); - const valueVal = getString(el, "value"); + const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); + const valueProps = readValueProps(el); const zag = new ColorPicker(el, { id: el.id, - ...(controlled - ? { value: valueVal ? parse(valueVal) : undefined } - : { defaultValue: defaultVal ? parse(defaultVal) : undefined }), - name: getString(el, "name") ?? el.id, - format: - getString<"rgba" | "hsla" | "hsba" | "hex">(el, "format", [ - "rgba", - "hsla", - "hsba", - "hex", - ]) ?? "rgba", - defaultFormat: getString<"rgba" | "hsla" | "hsba" | "hex">(el, "defaultFormat", [ - "rgba", - "hsla", - "hsba", - "hex", - ]), - closeOnSelect: getBoolean(el, "closeOnSelect") !== false, - ...(controlled - ? { open: getBoolean(el, "open") } - : { defaultOpen: getBoolean(el, "defaultOpen") }), - openAutoFocus: getBoolean(el, "openAutoFocus") !== false, + ...valueProps, + name: getString(el, "name"), + defaultFormat: "rgba", + closeOnSelect: getBoolean(el, "closeOnSelect"), + defaultOpen: false, + openAutoFocus: getBoolean(el, "openAutoFocus"), disabled: getBoolean(el, "disabled"), invalid: getBoolean(el, "invalid"), readOnly: getBoolean(el, "readOnly"), required: getBoolean(el, "required"), dir: getDir(el), - positioning: parsePositioning(el.dataset.positioning), + positioning: readPositioningOptions(el), onValueChange: (details: ValueChangeDetails) => { - const eventName = getString(el, "onValueChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { - valueAsString: details.valueAsString, + syncColorHiddenAndNotify(el, details.valueAsString); + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, - }); - } - const eventNameClient = getString(el, "onValueChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); - } + valueAsString: details.valueAsString, + } as Record, + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); }, onValueChangeEnd: (details: ValueChangeDetails) => { - const eventName = getString(el, "onValueChangeEnd"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { - valueAsString: details.valueAsString, + syncColorHiddenAndNotify(el, details.valueAsString); + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, - }); - } - const eventNameClient = getString(el, "onValueChangeEndClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); - } + valueAsString: details.valueAsString, + } as Record, + serverEventName: getString(el, "onValueChangeEnd"), + clientEventName: getString(el, "onValueChangeEndClient"), + }); }, onOpenChange: (details: OpenChangeDetails) => { - const eventName = getString(el, "onOpenChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { open: details.open, id: el.id }); - } - const eventNameClient = getString(el, "onOpenChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { open: details.open, id: el.id }, - }) - ); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, open: details.open } as Record, + serverEventName: getString(el, "onOpenChange"), + clientEventName: getString(el, "onOpenChangeClient"), + }); }, onFormatChange: (details: FormatChangeDetails) => { - const eventName = getString(el, "onFormatChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { format: details.format, id: el.id }); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, format: details.format } as Record, + serverEventName: getString(el, "onFormatChange"), + clientEventName: getString(el, "onFormatChangeClient"), + }); }, onPointerDownOutside: () => { - const eventName = getString(el, "onPointerDownOutside"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { id: el.id }); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id } as Record, + serverEventName: getString(el, "onPointerDownOutside"), + clientEventName: getString(el, "onPointerDownOutsideClient"), + }); }, onFocusOutside: () => { - const eventName = getString(el, "onFocusOutside"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { id: el.id }); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id } as Record, + serverEventName: getString(el, "onFocusOutside"), + clientEventName: getString(el, "onFocusOutsideClient"), + }); }, onInteractOutside: () => { - const eventName = getString(el, "onInteractOutside"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { id: el.id }); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id } as Record, + serverEventName: getString(el, "onInteractOutside"), + clientEventName: getString(el, "onInteractOutsideClient"), + }); }, } as unknown as Props); zag.init(); this.colorPicker = zag; this.handlers = []; - this.onSetOpen = (event: Event) => { - const { open } = (event as CustomEvent<{ open: boolean }>).detail; - zag.api.setOpen(open); - }; - el.addEventListener("phx:color-picker:set-open", this.onSetOpen); - this.onSetValue = (event: Event) => { const { value } = (event as CustomEvent<{ value: string }>).detail; zag.api.setValue(value); }; - el.addEventListener("phx:color-picker:set-value", this.onSetValue); - - this.onSetFormat = (event: Event) => { - const { format } = (event as CustomEvent<{ format: string }>).detail; - zag.api.setFormat(format as ColorFormat); - }; - el.addEventListener("phx:color-picker:set-format", this.onSetFormat); + el.addEventListener("corex:color-picker:set-value", this.onSetValue); this.handlers.push( - this.handleEvent( - "color_picker_set_open", - (payload: { color_picker_id?: string; open: boolean }) => { - const targetId = payload.color_picker_id; - if (targetId) { - const matches = el.id === targetId || el.id === `color-picker:${targetId}`; - if (!matches) return; - } - zag.api.setOpen(payload.open); - } - ) - ); - - this.handlers.push( - this.handleEvent( - "color_picker_set_value", - (payload: { color_picker_id?: string; value: string }) => { - const targetId = payload.color_picker_id; - if (targetId) { - const matches = el.id === targetId || el.id === `color-picker:${targetId}`; - if (!matches) return; - } - zag.api.setValue(payload.value); - } - ) - ); - - this.handlers.push( - this.handleEvent( - "color_picker_set_format", - (payload: { color_picker_id?: string; format: string }) => { - const targetId = payload.color_picker_id; - if (targetId) { - const matches = el.id === targetId || el.id === `color-picker:${targetId}`; - if (!matches) return; - } - zag.api.setFormat(payload.format as ColorFormat); - } - ) + this.handleEvent("color_picker_set_value", (payload: { value: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + zag.api.setValue(payload.value); + }) ); }, updated(this: object & HookInterface & ColorPickerHookState) { const el = this.el; - const controlled = getBoolean(el, "controlled"); - const defaultVal = getString(el, "defaultValue"); - const valueVal = getString(el, "value"); + const valueProps = readValueProps(el); this.colorPicker?.updateProps({ - ...(controlled - ? { value: valueVal ? parse(valueVal) : undefined } - : { defaultValue: defaultVal ? parse(defaultVal) : undefined }), - name: getString(el, "name") ?? el.id, - format: - getString<"rgba" | "hsla" | "hsba" | "hex">(el, "format", [ - "rgba", - "hsla", - "hsba", - "hex", - ]) ?? "rgba", - defaultFormat: getString<"rgba" | "hsla" | "hsba" | "hex">(el, "defaultFormat", [ - "rgba", - "hsla", - "hsba", - "hex", - ]), - closeOnSelect: getBoolean(el, "closeOnSelect") !== false, - ...(controlled - ? { open: getBoolean(el, "open") } - : { defaultOpen: getBoolean(el, "defaultOpen") }), - openAutoFocus: getBoolean(el, "openAutoFocus") !== false, + ...valueProps, + name: getString(el, "name"), + closeOnSelect: getBoolean(el, "closeOnSelect"), + openAutoFocus: getBoolean(el, "openAutoFocus"), disabled: getBoolean(el, "disabled"), invalid: getBoolean(el, "invalid"), readOnly: getBoolean(el, "readOnly"), required: getBoolean(el, "required"), dir: getDir(el), - positioning: parsePositioning(el.dataset.positioning), + positioning: readPositioningOptions(el), } as Partial); }, destroyed(this: object & HookInterface & ColorPickerHookState) { - if (this.onSetOpen) { - this.el.removeEventListener("phx:color-picker:set-open", this.onSetOpen); - } if (this.onSetValue) { - this.el.removeEventListener("phx:color-picker:set-value", this.onSetValue); - } - if (this.onSetFormat) { - this.el.removeEventListener("phx:color-picker:set-format", this.onSetFormat); + this.el.removeEventListener("corex:color-picker:set-value", this.onSetValue); } if (this.handlers) { for (const h of this.handlers) { diff --git a/assets/hooks/combobox.ts b/assets/hooks/combobox.ts index dd4a0923..1ceeece6 100644 --- a/assets/hooks/combobox.ts +++ b/assets/hooks/combobox.ts @@ -1,157 +1,137 @@ 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 { Combobox } from "../components/combobox"; import type { Props, InputValueChangeDetails, OpenChangeDetails, ValueChangeDetails, - PositioningOptions, } from "@zag-js/combobox"; -import type { Direction } from "@zag-js/types"; - -import { getString, getBoolean, getStringList } from "../lib/util"; +import { getString, getBoolean, getStringList, getDir, canPushEvent } from "../lib/util"; +import { performRedirect, readDomItemRedirect } from "../lib/redirect"; +import { idMatches, readPayloadId, notifyChange } from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; +import { readPositioningOptions } from "../lib/positioning"; type ComboboxHookState = { combobox?: Combobox; - handlers?: Array; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; }; -function snakeToCamel(str: string): string { - return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +function comboboxValueBinding(el: HTMLElement): { value: string[] } | { defaultValue: string[] } { + const controlled = getBoolean(el, "controlled"); + if (controlled) { + return { value: getStringList(el, "value") ?? [] }; + } + return { defaultValue: getStringList(el, "defaultValue") ?? [] }; } -function transformPositioningOptions(obj: Record): PositioningOptions { - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - const camelKey = snakeToCamel(key); - result[camelKey] = value; - } - return result as PositioningOptions; +function buildComboboxProps( + el: HTMLElement, + pushEvent: (name: string, payload: Record) => void, + canPush: () => boolean, + liveSocket: HookInterface["liveSocket"] +): Props { + const redirectOn = getBoolean(el, "redirect"); + return { + id: el.id, + disabled: getBoolean(el, "disabled"), + placeholder: getString(el, "placeholder"), + alwaysSubmitOnEnter: getBoolean(el, "alwaysSubmitOnEnter"), + autoFocus: getBoolean(el, "autoFocus"), + closeOnSelect: getBoolean(el, "closeOnSelect"), + dir: getDir(el), + inputBehavior: getString<"autohighlight" | "autocomplete" | "none">(el, "inputBehavior"), + loopFocus: getBoolean(el, "loopFocus"), + multiple: redirectOn ? false : getBoolean(el, "multiple"), + invalid: getBoolean(el, "invalid"), + allowCustomValue: false, + selectionBehavior: "replace", + name: getString(el, "name"), + form: getString(el, "form"), + readOnly: getBoolean(el, "readOnly"), + required: getBoolean(el, "required"), + positioning: readPositioningOptions(el), + onOpenChange: (details: OpenChangeDetails) => { + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { + id: el.id, + open: details.open, + reason: details.reason, + value: details.value, + } as Record, + serverEventName: getString(el, "onOpenChange"), + clientEventName: getString(el, "onOpenChangeClient"), + }); + }, + onInputValueChange: (details: InputValueChangeDetails) => { + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { + id: el.id, + value: details.inputValue, + reason: details.reason, + } as Record, + serverEventName: getString(el, "onInputValueChange"), + clientEventName: getString(el, "onInputValueChangeClient"), + }); + }, + onValueChange: (details: ValueChangeDetails) => { + const firstValue = details.value.length > 0 ? String(details.value[0]) : null; + if (redirectOn && firstValue) { + const itemEl = el.querySelector( + `[data-scope="combobox"][data-part="item"][data-value="${CSS.escape(firstValue)}"]` + ); + performRedirect(readDomItemRedirect(itemEl, firstValue), { liveSocket }); + } + { + const hidden = el.querySelector( + '[data-scope="combobox"][data-part="hidden-input"]' + ); + if (hidden) { + const list = details.value.map((v) => String(v)); + hidden.value = + list.length === 0 ? "" : getBoolean(el, "multiple") ? list.join(",") : (list[0] ?? ""); + hidden.dispatchEvent(new Event("input", { bubbles: true })); + hidden.dispatchEvent(new Event("change", { bubbles: true })); + } + } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { + id: el.id, + value: details.value, + items: details.items, + } as Record, + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); + }, + } as Props; } const ComboboxHook: Hook = { mounted(this: object & HookInterface & ComboboxHookState) { const el = this.el; const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); - const allItems = JSON.parse(el.dataset.collection || "[]"); + const allItems = JSON.parse(el.getAttribute("data-items") ?? "[]"); const hasGroups = allItems.some((item: { group?: unknown }) => Boolean(item.group)); - const props: Props = { - id: el.id, - ...(getBoolean(el, "controlled") - ? { value: getStringList(el, "value") } - : { defaultValue: getStringList(el, "defaultValue") }), - disabled: getBoolean(el, "disabled"), - placeholder: getString(el, "placeholder"), - alwaysSubmitOnEnter: getBoolean(el, "alwaysSubmitOnEnter"), - autoFocus: getBoolean(el, "autoFocus"), - closeOnSelect: getBoolean(el, "closeOnSelect"), - dir: getString(el, "dir", ["ltr", "rtl"]), - inputBehavior: getString(el, "inputBehavior", ["autohighlight", "autocomplete", "none"]), - loopFocus: getBoolean(el, "loopFocus"), - multiple: getBoolean(el, "multiple"), - invalid: getBoolean(el, "invalid"), - allowCustomValue: false, - selectionBehavior: "replace", - name: getString(el, "name"), - form: getString(el, "form"), - readOnly: getBoolean(el, "readOnly"), - required: getBoolean(el, "required"), - positioning: (() => { - const positioningJson = el.dataset.positioning; - if (positioningJson) { - try { - const parsed = JSON.parse(positioningJson); - return transformPositioningOptions(parsed); - } catch { - return undefined; - } - } - return undefined; - })(), - onOpenChange: (details: OpenChangeDetails) => { - const eventName = getString(el, "onOpenChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - pushEvent(eventName, { - open: details.open, - reason: details.reason, - value: details.value, - id: el.id, - }); - } - - const eventNameClient = getString(el, "onOpenChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: getBoolean(el, "bubble"), - detail: { - open: details.open, - reason: details.reason, - value: details.value, - id: el.id, - }, - }) - ); - } - }, - onInputValueChange: (details: InputValueChangeDetails) => { - const eventName = getString(el, "onInputValueChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - pushEvent(eventName, { - value: details.inputValue, - reason: details.reason, - id: el.id, - }); - } - - const eventNameClient = getString(el, "onInputValueChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: getBoolean(el, "bubble"), - detail: { - value: details.inputValue, - reason: details.reason, - id: el.id, - }, - }) - ); - } - }, - onValueChange: (details: ValueChangeDetails) => { - const hiddenInput = el.querySelector( - '[data-scope="combobox"][data-part="input"]' - ); - if (hiddenInput) { - hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); - } - const eventName = getString(el, "onValueChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - pushEvent(eventName, { - value: details.value, - items: details.items, - id: el.id, - }); - } - - const eventNameClient = getString(el, "onValueChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: getBoolean(el, "bubble"), - detail: { - value: details.value, - items: details.items, - id: el.id, - }, - }) - ); - } - }, - }; + const props = { + ...buildComboboxProps(el, pushEvent, canPush, this.liveSocket), + ...comboboxValueBinding(el), + } as Props; const combobox = new Combobox(el, props); combobox.hasGroups = hasGroups; @@ -159,40 +139,60 @@ const ComboboxHook: Hook = { combobox.init(); this.combobox = combobox; - this.handlers = []; + + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:combobox:set-value", (event) => { + combobox.api.setValue(event.detail.value); + }); + + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("combobox_set_value", (payload: { id?: string; value: string[] }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + combobox.api.setValue(payload.value); + }); }, updated(this: object & HookInterface & ComboboxHookState) { - const newCollection = JSON.parse(this.el.dataset.collection || "[]"); + const newCollection = JSON.parse(this.el.getAttribute("data-items") ?? "[]"); const hasGroups = newCollection.some((item: { group?: unknown }) => Boolean(item.group)); - if (this.combobox) { - this.combobox.hasGroups = hasGroups; - this.combobox.setAllOptions(newCollection); - this.combobox.render(); - this.combobox.updateProps({ - ...(getBoolean(this.el, "controlled") - ? { value: getStringList(this.el, "value") } - : { defaultValue: getStringList(this.el, "defaultValue") }), - collection: this.combobox.getCollection(), - name: getString(this.el, "name"), - form: getString(this.el, "form"), - disabled: getBoolean(this.el, "disabled"), - multiple: getBoolean(this.el, "multiple"), - dir: getString(this.el, "dir", ["ltr", "rtl"]), - invalid: getBoolean(this.el, "invalid"), - required: getBoolean(this.el, "required"), - readOnly: getBoolean(this.el, "readOnly"), - }); + if (!this.combobox) return; + + this.combobox.hasGroups = hasGroups; + this.combobox.setAllOptions(newCollection); + + const redirectOn = getBoolean(this.el, "redirect"); + const controlled = getBoolean(this.el, "controlled"); + + this.combobox.updateProps({ + collection: this.combobox.getCollection(), + id: this.el.id, + ...(controlled + ? { value: getStringList(this.el, "value") ?? [] } + : { defaultValue: getStringList(this.el, "defaultValue") ?? [] }), + name: getString(this.el, "name"), + form: getString(this.el, "form"), + dir: getDir(this.el), + disabled: getBoolean(this.el, "disabled"), + multiple: redirectOn ? false : getBoolean(this.el, "multiple"), + invalid: getBoolean(this.el, "invalid"), + required: getBoolean(this.el, "required"), + readOnly: getBoolean(this.el, "readOnly"), + placeholder: getString(this.el, "placeholder"), + } as Props); + + if (this.combobox.api.open) { + this.combobox.api.reposition(); } }, destroyed(this: object & HookInterface & ComboboxHookState) { - if (this.handlers) { - for (const handler of this.handlers) { - this.removeHandleEvent(handler); - } - } + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.combobox?.destroy(); }, }; diff --git a/assets/hooks/components.d.ts b/assets/hooks/components.d.ts index 3047e0a8..6bddf18c 100644 --- a/assets/hooks/components.d.ts +++ b/assets/hooks/components.d.ts @@ -22,6 +22,9 @@ declare module "corex/checkbox" { declare module "corex/clipboard" { export const Clipboard: CorexHook; } +declare module "corex/code" { + export const Code: CorexHook; +} declare module "corex/collapsible" { export const Collapsible: CorexHook; } @@ -82,6 +85,9 @@ declare module "corex/timer" { declare module "corex/toast" { export const Toast: CorexHook; } +declare module "corex/tooltip" { + export const Tooltip: CorexHook; +} declare module "corex/toggle-group" { export const ToggleGroup: CorexHook; } diff --git a/assets/hooks/corex.ts b/assets/hooks/corex.ts index 58456df9..c8360959 100644 --- a/assets/hooks/corex.ts +++ b/assets/hooks/corex.ts @@ -1,5 +1,24 @@ import type { Hook } from "phoenix_live_view"; +export type { + AccordionChangedDetail, + TreeViewExpandedChangedDetail, + TreeViewSelectionChangedDetail, + DialogOpenChangedDetail, +} from "../lib/event-details"; + +export type { Animator, AnimateHeightOptions } from "../lib/custom-animation"; + +export { + applyClosedHeight, + applyOpenHeight, + animateHeightOpen, + animateHeightClose, + initCustomCollections, + findAccordionContent, + findTreeBranch, +} from "../lib/custom-animation"; + type HookModule = Record | undefined>; function createLazyHook(importFn: () => Promise, exportName: string): Hook { @@ -35,6 +54,7 @@ export const Hooks = { Carousel: createLazyHook(() => import("corex/carousel"), "Carousel"), Checkbox: createLazyHook(() => import("corex/checkbox"), "Checkbox"), Clipboard: createLazyHook(() => import("corex/clipboard"), "Clipboard"), + Code: createLazyHook(() => import("corex/code"), "Code"), Collapsible: createLazyHook(() => import("corex/collapsible"), "Collapsible"), Combobox: createLazyHook(() => import("corex/combobox"), "Combobox"), ColorPicker: createLazyHook(() => import("corex/color-picker"), "ColorPicker"), @@ -55,6 +75,7 @@ export const Hooks = { Tabs: createLazyHook(() => import("corex/tabs"), "Tabs"), Timer: createLazyHook(() => import("corex/timer"), "Timer"), Toast: createLazyHook(() => import("corex/toast"), "Toast"), + Tooltip: createLazyHook(() => import("corex/tooltip"), "Tooltip"), ToggleGroup: createLazyHook(() => import("corex/toggle-group"), "ToggleGroup"), TreeView: createLazyHook(() => import("corex/tree-view"), "TreeView"), }; diff --git a/assets/hooks/date-picker.ts b/assets/hooks/date-picker.ts index 0ca29111..b0893a12 100644 --- a/assets/hooks/date-picker.ts +++ b/assets/hooks/date-picker.ts @@ -1,16 +1,41 @@ import type { Hook } from "phoenix_live_view"; import type { HookInterface, CallbackRef } from "phoenix_live_view/assets/js/types/view_hook"; -import { DatePicker } from "../components/date-picker"; +import { + DatePicker, + buildZagDatePickerTranslations, + type DatePickerMessageMap, +} from "../components/date-picker"; import type { ValueChangeDetails, Props } from "@zag-js/date-picker"; import type { Direction } from "@zag-js/types"; import * as datePicker from "@zag-js/date-picker"; -import { getString, getBoolean, getStringList, getNumber } from "../lib/util"; +import { getString, getBoolean, getStringList, getNumber, canPushEvent } from "../lib/util"; +import { readPositioningOptions } from "../lib/positioning"; +import { notifyChange } from "../lib/respond-to"; -function toISOString(d: { year: number; month: number; day: number }): string { - const pad = (n: number) => String(n).padStart(2, "0"); - return `${d.year}-${pad(d.month)}-${pad(d.day)}`; +function valueToIsoString(d: unknown): string { + if (d == null) return ""; + return String(d); +} + +function resolveZagDatePickerTranslations( + el: HTMLElement +): { translations: NonNullable } | Record { + const raw = el.dataset.translation; + if (!raw) { + return {}; + } + try { + const tr = JSON.parse(raw) as DatePickerMessageMap; + return { translations: buildZagDatePickerTranslations(tr) }; + } catch { + return {}; + } +} + +function resolveCloseOnSelect(el: HTMLElement): boolean { + return getBoolean(el, "closeOnSelect"); } type DatePickerHookState = { @@ -24,10 +49,10 @@ const DatePickerHook: Hook = { const el = this.el; const pushEvent = this.pushEvent.bind(this); const liveSocket = this.liveSocket; + const canPush = () => canPushEvent(this.liveSocket); const min = getString(el, "min"); const max = getString(el, "max"); - const positioningJson = getString(el, "positioning"); const parseList = (v: string[] | undefined) => v ? v.map((x) => datePicker.parse(x)) : undefined; const parseOne = (v: string | undefined) => (v ? datePicker.parse(v) : undefined); @@ -38,8 +63,8 @@ const DatePickerHook: Hook = { ? { value: parseList(getStringList(el, "value")) } : { defaultValue: parseList(getStringList(el, "defaultValue")) }), defaultFocusedValue: parseOne(getString(el, "focusedValue")), - defaultView: getString<"day" | "month" | "year">(el, "defaultView", ["day", "month", "year"]), - dir: getString(el, "dir", ["ltr", "rtl"]), + defaultView: getString<"day" | "month" | "year">(el, "defaultView"), + dir: getString(el, "dir"), locale: getString(el, "locale"), timeZone: getString(el, "timeZone"), disabled: getBoolean(el, "disabled"), @@ -47,27 +72,26 @@ const DatePickerHook: Hook = { required: getBoolean(el, "required"), invalid: getBoolean(el, "invalid"), outsideDaySelectable: getBoolean(el, "outsideDaySelectable"), - closeOnSelect: getBoolean(el, "closeOnSelect"), + closeOnSelect: resolveCloseOnSelect(el), min: min ? datePicker.parse(min) : undefined, max: max ? datePicker.parse(max) : undefined, - numOfMonths: getNumber(el, "numOfMonths"), startOfWeek: getNumber(el, "startOfWeek"), fixedWeeks: getBoolean(el, "fixedWeeks"), - selectionMode: getString<"single" | "multiple" | "range">(el, "selectionMode", [ - "single", - "multiple", - "range", - ]), + selectionMode: getString<"single" | "multiple" | "range">(el, "selectionMode"), + maxSelectedDates: getNumber(el, "maxSelectedDates"), placeholder: getString(el, "placeholder"), - minView: getString<"day" | "month" | "year">(el, "minView", ["day", "month", "year"]), - maxView: getString<"day" | "month" | "year">(el, "maxView", ["day", "month", "year"]), + minView: getString<"day" | "month" | "year">(el, "minView"), + maxView: getString<"day" | "month" | "year">(el, "maxView"), + defaultOpen: false, inline: getBoolean(el, "inline"), - positioning: positioningJson ? JSON.parse(positioningJson) : undefined, + positioning: readPositioningOptions(el), + ...resolveZagDatePickerTranslations(el), onValueChange: (details: ValueChangeDetails) => { const isoStr = details.value?.length ? details.value - .map((d: { year: number; month: number; day: number }) => toISOString(d)) + .map((d: unknown) => valueToIsoString(d)) + .filter(Boolean) .join(",") : ""; @@ -78,13 +102,14 @@ const DatePickerHook: Hook = { hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); } - const eventName = getString(el, "onValueChange"); - if (eventName && liveSocket.main.isConnected()) { - pushEvent(eventName, { - id: el.id, - value: isoStr || null, - }); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, value: isoStr || null } as Record, + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); }, onFocusChange: (details: { focused?: boolean }) => { const eventName = getString(el, "onFocusChange"); @@ -115,24 +140,20 @@ const DatePickerHook: Hook = { } }, onOpenChange: (details: { open?: boolean }) => { - const eventName = getString(el, "onOpenChange"); - if (eventName && liveSocket.main.isConnected()) { - pushEvent(eventName, { - id: el.id, - open: details.open, - }); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, open: details.open } as Record, + serverEventName: getString(el, "onOpenChange"), + clientEventName: getString(el, "onOpenChangeClient"), + }); }, } as Props); datePickerInstance.init(); this.datePicker = datePickerInstance; - const inputWrapper = el.querySelector( - '[data-scope="date-picker"][data-part="input-wrapper"]' - ); - if (inputWrapper) inputWrapper.removeAttribute("data-loading"); - this.handlers = []; this.handlers.push( @@ -152,75 +173,52 @@ const DatePickerHook: Hook = { datePickerInstance.api.setValue([datePicker.parse(value)]); } }; - el.addEventListener("phx:date-picker:set-value", this.onSetValue); + el.addEventListener("corex:date-picker:set-value", this.onSetValue); }, updated(this: object & HookInterface & DatePickerHookState) { const el = this.el; - const inputWrapper = el.querySelector( - '[data-scope="date-picker"][data-part="input-wrapper"]' - ); - if (inputWrapper) inputWrapper.removeAttribute("data-loading"); - - const parseList = (v: string[] | undefined) => - v ? v.map((x) => datePicker.parse(x)) : undefined; const min = getString(el, "min"); const max = getString(el, "max"); - const positioningJson = getString(el, "positioning"); - const isControlled = getBoolean(el, "controlled"); const focusedStr = getString(el, "focusedValue"); + const controlled = getBoolean(el, "controlled"); + const valueList = getStringList(el, "value"); this.datePicker?.updateProps({ - ...(getBoolean(el, "controlled") - ? { value: parseList(getStringList(el, "value")) } - : { defaultValue: parseList(getStringList(el, "defaultValue")) }), + ...(controlled + ? { + value: (valueList ?? []).map((x) => datePicker.parse(x)), + } + : {}), defaultFocusedValue: focusedStr ? datePicker.parse(focusedStr) : undefined, - defaultView: getString<"day" | "month" | "year">(el, "defaultView", ["day", "month", "year"]), - dir: getString(this.el, "dir", ["ltr", "rtl"]), - locale: getString(this.el, "locale"), - timeZone: getString(this.el, "timeZone"), - disabled: getBoolean(this.el, "disabled"), - readOnly: getBoolean(this.el, "readOnly"), - required: getBoolean(this.el, "required"), - invalid: getBoolean(this.el, "invalid"), - outsideDaySelectable: getBoolean(this.el, "outsideDaySelectable"), - closeOnSelect: getBoolean(this.el, "closeOnSelect"), + defaultView: getString<"day" | "month" | "year">(el, "defaultView"), + dir: getString(el, "dir"), + locale: getString(el, "locale"), + timeZone: getString(el, "timeZone"), + disabled: getBoolean(el, "disabled"), + readOnly: getBoolean(el, "readOnly"), + required: getBoolean(el, "required"), + invalid: getBoolean(el, "invalid"), + outsideDaySelectable: getBoolean(el, "outsideDaySelectable"), + closeOnSelect: resolveCloseOnSelect(el), min: min ? datePicker.parse(min) : undefined, max: max ? datePicker.parse(max) : undefined, - numOfMonths: getNumber(this.el, "numOfMonths"), - startOfWeek: getNumber(this.el, "startOfWeek"), - fixedWeeks: getBoolean(this.el, "fixedWeeks"), - selectionMode: getString<"single" | "multiple" | "range">(this.el, "selectionMode", [ - "single", - "multiple", - "range", - ]), - placeholder: getString(this.el, "placeholder"), - minView: getString<"day" | "month" | "year">(this.el, "minView", ["day", "month", "year"]), - maxView: getString<"day" | "month" | "year">(this.el, "maxView", ["day", "month", "year"]), - inline: getBoolean(this.el, "inline"), - positioning: positioningJson ? JSON.parse(positioningJson) : undefined, - }); - - if (isControlled && this.datePicker) { - const serverValues = getStringList(el, "value"); - const serverIso = serverValues?.join(",") ?? ""; - const zagValue = this.datePicker.api.value; - const zagIso = zagValue?.length - ? zagValue - .map((d: { year: number; month: number; day: number }) => toISOString(d)) - .join(",") - : ""; - if (serverIso !== zagIso) { - const parsed = serverValues?.length ? serverValues.map((x) => datePicker.parse(x)) : []; - this.datePicker.api.setValue(parsed); - } - } + startOfWeek: getNumber(el, "startOfWeek"), + fixedWeeks: getBoolean(el, "fixedWeeks"), + selectionMode: getString<"single" | "multiple" | "range">(el, "selectionMode"), + maxSelectedDates: getNumber(el, "maxSelectedDates"), + placeholder: getString(el, "placeholder"), + minView: getString<"day" | "month" | "year">(el, "minView"), + maxView: getString<"day" | "month" | "year">(el, "maxView"), + inline: getBoolean(el, "inline"), + positioning: readPositioningOptions(el), + ...resolveZagDatePickerTranslations(el), + } as Props); }, destroyed(this: object & HookInterface & DatePickerHookState) { if (this.onSetValue) { - this.el.removeEventListener("phx:date-picker:set-value", this.onSetValue); + this.el.removeEventListener("corex:date-picker:set-value", this.onSetValue); } if (this.handlers) { for (const handler of this.handlers) { diff --git a/assets/hooks/dialog.ts b/assets/hooks/dialog.ts index a348a4a0..6ca089e3 100644 --- a/assets/hooks/dialog.ts +++ b/assets/hooks/dialog.ts @@ -1,21 +1,53 @@ 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 { Dialog } from "../components/dialog"; import type { OpenChangeDetails } from "@zag-js/dialog"; -import type { Direction } from "@zag-js/types"; -import { getString, getBoolean } from "../lib/util"; +import { getString, getBoolean, getDir, canPushEvent } from "../lib/util"; +import { idMatches, notifyChange, readPayloadId } from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; +import { + prepareInitialScaleState, + readScaleAnimationOptions, + runScaleAnimation, +} from "../lib/animation"; +import { type DialogOpenChangedDetail } from "../lib/event-details"; type DialogHookState = { dialog?: Dialog; - handlers?: Array; - onSetOpen?: (event: Event) => void; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; + closePointerT?: ReturnType; + lastOpen?: boolean; }; +function getDialogUpdatePropsFromEl(el: HTMLElement) { + const softLock = el.dataset.animInteractionLocked === "true"; + return { + id: el.id, + ...(getBoolean(el, "controlled") + ? { open: getBoolean(el, "open") } + : { defaultOpen: getBoolean(el, "defaultOpen") }), + modal: getBoolean(el, "modal"), + closeOnInteractOutside: softLock ? false : getBoolean(el, "closeOnInteractOutside"), + closeOnEscape: softLock ? false : getBoolean(el, "closeOnEscapeKeyDown"), + preventScroll: getBoolean(el, "preventScroll"), + restoreFocus: getBoolean(el, "restoreFocus"), + dir: getDir(el), + }; +} + const DialogHook: Hook = { mounted(this: object & HookInterface & DialogHookState) { const el = this.el; + const self = this as object & HookInterface & DialogHookState; const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); + + self.lastOpen = getBoolean(el, "controlled") + ? (getBoolean(el, "open") ?? false) + : (getBoolean(el, "defaultOpen") ?? false); const dialog = new Dialog(el, { id: el.id, @@ -27,86 +59,164 @@ const DialogHook: Hook = { closeOnEscape: getBoolean(el, "closeOnEscapeKeyDown"), preventScroll: getBoolean(el, "preventScroll"), restoreFocus: getBoolean(el, "restoreFocus"), - dir: getString(el, "dir", ["ltr", "rtl"]), + dir: getDir(el), onOpenChange: (details: OpenChangeDetails) => { - const eventName = getString(el, "onOpenChange"); - if (eventName && this.liveSocket.main.isConnected()) { - pushEvent(eventName, { - id: el.id, - open: details.open, - }); + if (!details.open && (el.dataset.animation === "js" || el.dataset.animation === "custom")) { + if (self.closePointerT !== undefined) clearTimeout(self.closePointerT); + el.setAttribute("data-exit-anim", "running"); + + if (el.dataset.animation === "js") { + const closeOpts = readScaleAnimationOptions(el); + self.closePointerT = window.setTimeout( + () => { + el.setAttribute("data-exit-anim", "complete"); + const backdrop = el.querySelector( + '[data-scope="dialog"][data-part="backdrop"]' + ); + const positioner = el.querySelector( + '[data-scope="dialog"][data-part="positioner"]' + ); + if (backdrop) backdrop.style.pointerEvents = "none"; + if (positioner) positioner.style.pointerEvents = "none"; + self.closePointerT = undefined; + dialog.render(); + }, + Math.max(0, closeOpts.duration * 1000) + ); + } else { + self.closePointerT = window.setTimeout(() => { + el.setAttribute("data-exit-anim", "complete"); + self.closePointerT = undefined; + dialog.render(); + }, 0); + } + } else if (details.open) { + if (self.closePointerT !== undefined) { + clearTimeout(self.closePointerT); + self.closePointerT = undefined; + } + el.removeAttribute("data-exit-anim"); + el.removeAttribute("data-anim-interaction-locked"); + dialog.updateProps(getDialogUpdatePropsFromEl(el)); + dialog.render(); } - const eventNameClient = getString(el, "onOpenChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { - id: el.id, - open: details.open, - }, - }) + const previousOpen = self.lastOpen ?? false; + self.lastOpen = details.open; + + const payload: DialogOpenChangedDetail = { + id: el.id, + open: details.open, + previousOpen, + }; + + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: payload as unknown as Record, + serverEventName: getString(el, "onOpenChange"), + clientEventName: getString(el, "onOpenChangeClient"), + }); + + if (el.dataset.animation === "js") { + const animOpts = readScaleAnimationOptions(el); + if (animOpts.blockInteraction) { + el.dataset.animInteractionLocked = "true"; + dialog.updateProps(getDialogUpdatePropsFromEl(el)); + dialog.render(); + } + const backdrop = el.querySelector( + '[data-scope="dialog"][data-part="backdrop"]' ); + const content = el.querySelector( + '[data-scope="dialog"][data-part="content"]' + ); + const a1 = backdrop ? runScaleAnimation(backdrop, details.open, animOpts) : null; + const a2 = content ? runScaleAnimation(content, details.open, animOpts) : null; + const onDone = () => { + if (animOpts.blockInteraction) { + el.removeAttribute("data-anim-interaction-locked"); + dialog.updateProps(getDialogUpdatePropsFromEl(el)); + dialog.render(); + } + }; + const promises: Promise[] = []; + if (a1) promises.push(a1.finished); + if (a2) promises.push(a2.finished); + if (promises.length > 0) { + void Promise.all(promises).then(onDone, onDone); + } else { + onDone(); + } } + // "custom" mode: external animator handles everything via on_open_change_client event }, }); dialog.init(); this.dialog = dialog; - this.onSetOpen = (event: Event) => { - const { open } = (event as CustomEvent<{ open: boolean }>).detail; + // Only prepare initial scale state for "js" mode + if (el.dataset.animation === "js") { + const opts = readScaleAnimationOptions(el); + prepareInitialScaleState( + el, + '[data-scope="dialog"][data-part="backdrop"], [data-scope="dialog"][data-part="content"]', + opts, + (sub) => { + if (sub.dataset.part === "backdrop") return { scale: false }; + } + ); + } + + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:dialog:set-open", (event) => { + const { open } = event.detail; dialog.api.setOpen(open); - }; - el.addEventListener("phx:dialog:set-open", this.onSetOpen); - - this.handlers = []; - - this.handlers.push( - this.handleEvent("dialog_set_open", (payload: { dialog_id?: string; open: boolean }) => { - const targetId = payload.dialog_id; - if (targetId && targetId !== el.id) return; - dialog.api.setOpen(payload.open); - }) - ); - - this.handlers.push( - this.handleEvent("dialog_open", () => { - this.pushEvent("dialog_open_response", { - value: dialog.api.open, - }); - }) - ); - }, + }); - updated(this: object & HookInterface & DialogHookState) { - this.dialog?.updateProps({ - id: this.el.id, - ...(getBoolean(this.el, "controlled") - ? { open: getBoolean(this.el, "open") } - : { defaultOpen: getBoolean(this.el, "defaultOpen") }), - modal: getBoolean(this.el, "modal"), - closeOnInteractOutside: getBoolean(this.el, "closeOnInteractOutside"), - closeOnEscape: getBoolean(this.el, "closeOnEscapeKeyDown"), - preventScroll: getBoolean(this.el, "preventScroll"), - restoreFocus: getBoolean(this.el, "restoreFocus"), - dir: getString(this.el, "dir", ["ltr", "rtl"]), + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("dialog_set_open", (payload: unknown) => { + if (!payload || typeof payload !== "object") return; + const o = payload as { open?: boolean }; + if (!idMatches(el.id, readPayloadId(payload))) return; + if (typeof o.open === "boolean") dialog.api.setOpen(o.open); + }); + + registry.add("dialog_open", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (!canPush()) return; + this.pushEvent("dialog_open_response", { + id: el.id, + value: dialog.api.open, + }); }); }, - destroyed(this: object & HookInterface & DialogHookState) { - if (this.onSetOpen) { - this.el.removeEventListener("phx:dialog:set-open", this.onSetOpen); + updated(this: object & HookInterface & DialogHookState) { + if (getBoolean(this.el, "controlled")) { + this.lastOpen = getBoolean(this.el, "open") ?? false; } + this.dialog?.updateProps(getDialogUpdatePropsFromEl(this.el)); + }, - if (this.handlers) { - for (const handler of this.handlers) { - this.removeHandleEvent(handler); - } + destroyed(this: object & HookInterface & DialogHookState) { + const self = this as object & HookInterface & DialogHookState; + if (self.closePointerT !== undefined) { + clearTimeout(self.closePointerT); + self.closePointerT = undefined; } - + this.el.removeAttribute("data-exit-anim"); + this.el.removeAttribute("data-anim-interaction-locked"); + this.dialog?.updateProps(getDialogUpdatePropsFromEl(this.el)); + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.dialog?.destroy(); }, }; diff --git a/assets/hooks/editable.ts b/assets/hooks/editable.ts index 813c12a4..b262df95 100644 --- a/assets/hooks/editable.ts +++ b/assets/hooks/editable.ts @@ -1,27 +1,34 @@ 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 { Editable } from "../components/editable"; import type { Props, ValueChangeDetails } from "@zag-js/editable"; -import { getString, getBoolean, getDir } from "../lib/util"; +import { getString, getBoolean, getDir, canPushEvent } from "../lib/util"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; +import { idMatches, notifyChange, readPayloadId, readPayloadValue } from "../lib/respond-to"; type EditableHookState = { editable?: Editable; - handlers?: Array; + domRegistry?: ReturnType; + handleRegistry?: ReturnType; }; -const EditableHook: Hook = { +function dataDefaultValue(el: HTMLElement): string { + return getString(el, "defaultValue") ?? ""; +} + +const EditableHook: Hook & EditableHookState, HTMLElement> = { mounted(this: object & HookInterface & EditableHookState) { const el = this.el; - const value = getString(el, "value"); - const defaultValue = getString(el, "defaultValue"); - const controlled = getBoolean(el, "controlled"); + const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const placeholder = getString(el, "placeholder"); const activationMode = getString(el, "activationMode") as "focus" | "dblclick" | undefined; const selectOnFocus = getBoolean(el, "selectOnFocus"); const zag = new Editable(el, { id: el.id, - ...(controlled && value !== undefined ? { value } : { defaultValue: defaultValue ?? "" }), + defaultValue: dataDefaultValue(el), disabled: getBoolean(el, "disabled"), readOnly: getBoolean(el, "readOnly"), required: getBoolean(el, "required"), @@ -45,45 +52,63 @@ const EditableHook: Hook = { inputEl.dispatchEvent(new Event("input", { bubbles: true })); inputEl.dispatchEvent(new Event("change", { bubbles: true })); } - const eventName = getString(el, "onValueChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { value: details.value, id: el.id }); - } - const clientName = getString(el, "onValueChangeClient"); - if (clientName) { - el.dispatchEvent( - new CustomEvent(clientName, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { + id: el.id, + value: details.value, + } as Record, + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); }, } as Props); zag.init(); this.editable = zag; - this.handlers = []; + + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:editable:set-value", (event) => { + const raw = event.detail?.value; + zag.api.setValue(raw === undefined || raw === null ? "" : String(raw)); + }); + + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("editable_set_value", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + zag.api.setValue(readPayloadValue(payload)); + }); }, updated(this: object & HookInterface & EditableHookState) { - const value = getString(this.el, "value"); - const controlled = getBoolean(this.el, "controlled"); + const el = this.el; + const dv = dataDefaultValue(el); + if (this.editable && !this.editable.api.editing && dv !== this.editable.api.value) { + this.editable.api.setValue(dv); + } this.editable?.updateProps({ - id: this.el.id, - ...(controlled && value !== undefined ? { value } : {}), - disabled: getBoolean(this.el, "disabled"), - readOnly: getBoolean(this.el, "readOnly"), - required: getBoolean(this.el, "required"), - invalid: getBoolean(this.el, "invalid"), - name: getString(this.el, "name"), - form: getString(this.el, "form"), + id: el.id, + disabled: getBoolean(el, "disabled"), + readOnly: getBoolean(el, "readOnly"), + required: getBoolean(el, "required"), + invalid: getBoolean(el, "invalid"), + name: getString(el, "name"), + form: getString(el, "form"), + dir: getDir(el), + ...(getBoolean(el, "controlledEdit") + ? { edit: getBoolean(el, "edit") } + : { defaultEdit: getBoolean(el, "defaultEdit") }), } as Partial); }, destroyed(this: object & HookInterface & EditableHookState) { - if (this.handlers) { - for (const h of this.handlers) this.removeHandleEvent(h); - } + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.editable?.destroy(); }, }; diff --git a/assets/hooks/floating-panel.ts b/assets/hooks/floating-panel.ts index e374c3de..54a2fe61 100644 --- a/assets/hooks/floating-panel.ts +++ b/assets/hooks/floating-panel.ts @@ -1,5 +1,5 @@ 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 { FloatingPanel } from "../components/floating-panel"; import type { Props, @@ -8,11 +8,15 @@ import type { SizeChangeDetails, StageChangeDetails, } from "@zag-js/floating-panel"; -import { getString, getBoolean, getDir } from "../lib/util"; +import { getString, getBoolean, getDir, canPushEvent } from "../lib/util"; +import { idMatches, notifyChange, readPayloadId } from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; type FloatingPanelHookState = { floatingPanel?: FloatingPanel; - handlers?: Array; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; }; function parseSize(val: string | undefined): { width: number; height: number } | undefined { @@ -41,19 +45,21 @@ function parsePoint(val: string | undefined): { x: number; y: number } | undefin return undefined; } -const FloatingPanelHook: Hook = { +const FloatingPanelHook: Hook< + object & HookInterface & FloatingPanelHookState, + HTMLElement +> = { mounted(this: object & HookInterface & FloatingPanelHookState) { const el = this.el; - const open = getBoolean(el, "open"); - const defaultOpen = getBoolean(el, "defaultOpen"); - const controlled = getBoolean(el, "controlled"); + const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const size = parseSize(el.dataset.size); const defaultSize = parseSize(el.dataset.defaultSize); const position = parsePoint(el.dataset.position); const defaultPosition = parsePoint(el.dataset.defaultPosition); const zag = new FloatingPanel(el, { id: el.id, - ...(controlled ? { open } : { defaultOpen }), + defaultOpen: false, draggable: getBoolean(el, "draggable") !== false, resizable: getBoolean(el, "resizable") !== false, allowOverflow: getBoolean(el, "allowOverflow") !== false, @@ -69,68 +75,84 @@ const FloatingPanelHook: Hook = { persistRect: getBoolean(el, "persistRect"), gridSize: Number(el.dataset.gridSize) || 1, onOpenChange: (details: OpenChangeDetails) => { - const eventName = getString(el, "onOpenChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { open: details.open, id: el.id }); - } - const clientName = getString(el, "onOpenChangeClient"); - if (clientName) { - el.dispatchEvent( - new CustomEvent(clientName, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { + id: el.id, + open: details.open, + } as Record, + serverEventName: getString(el, "onOpenChange"), + clientEventName: getString(el, "onOpenChangeClient"), + }); }, onPositionChange: (details: PositionChangeDetails) => { - const eventName = getString(el, "onPositionChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { - position: details.position, - id: el.id, - }); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, position: details.position } as Record, + serverEventName: getString(el, "onPositionChange"), + clientEventName: getString(el, "onPositionChangeClient"), + }); }, onSizeChange: (details: SizeChangeDetails) => { - const eventName = getString(el, "onSizeChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { - size: details.size, - id: el.id, - }); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, size: details.size } as Record, + serverEventName: getString(el, "onSizeChange"), + clientEventName: getString(el, "onSizeChangeClient"), + }); }, onStageChange: (details: StageChangeDetails) => { - const eventName = getString(el, "onStageChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { - stage: details.stage, - id: el.id, - }); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, stage: details.stage } as Record, + serverEventName: getString(el, "onStageChange"), + clientEventName: getString(el, "onStageChangeClient"), + }); }, } as Props); zag.init(); this.floatingPanel = zag; - this.handlers = []; + + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:floating-panel:set-open", (event) => { + const { open } = event.detail; + zag.api.setOpen(open); + }); + + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("floating_panel_set_open", (payload: unknown) => { + if (!payload || typeof payload !== "object") return; + const o = payload as { open?: boolean }; + if (!idMatches(el.id, readPayloadId(payload))) return; + if (typeof o.open === "boolean") zag.api.setOpen(o.open); + }); }, updated(this: object & HookInterface & FloatingPanelHookState) { - const open = getBoolean(this.el, "open"); - const controlled = getBoolean(this.el, "controlled"); this.floatingPanel?.updateProps({ id: this.el.id, - ...(controlled ? { open } : {}), disabled: getBoolean(this.el, "disabled"), dir: getDir(this.el), } as Partial); }, destroyed(this: object & HookInterface & FloatingPanelHookState) { - if (this.handlers) { - for (const h of this.handlers) this.removeHandleEvent(h); - } + this.domRegistry?.teardown(); + this.domRegistry = undefined; + this.handleRegistry?.teardown(); + this.handleRegistry = undefined; this.floatingPanel?.destroy(); }, }; diff --git a/assets/hooks/listbox.ts b/assets/hooks/listbox.ts index 77449d4d..dd7b3f4a 100644 --- a/assets/hooks/listbox.ts +++ b/assets/hooks/listbox.ts @@ -1,10 +1,20 @@ 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 { collection } from "@zag-js/listbox"; import { Listbox } from "../components/listbox"; import type { Props, ValueChangeDetails } from "@zag-js/listbox"; -import type { Direction } from "@zag-js/types"; -import { getString, getBoolean, getStringList } from "../lib/util"; +import { getString, getBoolean, getStringList, getDir, canPushEvent } from "../lib/util"; +import { performRedirect, readDomItemRedirect } from "../lib/redirect"; +import { + parseRespondTo, + emitResponse, + idMatches, + readPayloadId, + notifyChange, + type RespondTo, +} from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; type ListboxItem = { id?: string; @@ -32,10 +42,52 @@ function buildCollection(items: ListboxItem[], hasGroups: boolean) { }); } +function listboxZagPropsBase( + el: HTMLElement, + liveSocket: HookInterface["liveSocket"], + pushEvent: (name: string, payload: Record) => void +): Omit, "collection" | "value" | "defaultValue"> { + const redirectOn = getBoolean(el, "redirect"); + return { + id: el.id, + disabled: getBoolean(el, "disabled"), + dir: getDir(el), + orientation: getString<"horizontal" | "vertical">(el, "orientation"), + loopFocus: getBoolean(el, "loopFocus"), + selectionMode: redirectOn + ? "single" + : getString<"single" | "multiple" | "extended">(el, "selectionMode"), + selectOnHighlight: getBoolean(el, "selectOnHighlight"), + deselectable: getBoolean(el, "deselectable"), + typeahead: getBoolean(el, "typeahead"), + onValueChange: (details: ValueChangeDetails) => { + const firstValue = details.value.length > 0 ? String(details.value[0]) : null; + if (redirectOn && firstValue) { + const itemEl = el.querySelector( + `[data-scope="listbox"][data-part="item"][data-value="${CSS.escape(firstValue)}"]` + ); + performRedirect(readDomItemRedirect(itemEl, firstValue), { liveSocket }); + } + notifyChange({ + el, + canPushServer: canPushEvent(liveSocket), + pushEvent, + payload: { + id: el.id, + value: details.value, + items: details.items, + } as Record, + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); + }, + }; +} + type ListboxHookState = { listbox?: Listbox; - handlers?: Array; - handleContentClick?: (e: MouseEvent) => void; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; }; const ListboxHook: Hook = { @@ -46,64 +98,58 @@ const ListboxHook: Hook = { const valueList = getStringList(el, "value"); const defaultValueList = getStringList(el, "defaultValue"); const controlled = getBoolean(el, "controlled"); + const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const zag = new Listbox(el, { - id: el.id, + ...listboxZagPropsBase(el, this.liveSocket, pushEvent), collection: buildCollection(allItems, hasGroups), ...(controlled && valueList ? { value: valueList } : { defaultValue: defaultValueList ?? [] }), - disabled: getBoolean(el, "disabled"), - dir: getString(el, "dir", ["ltr", "rtl"]), - orientation: getString<"horizontal" | "vertical">(el, "orientation", [ - "horizontal", - "vertical", - ]), - loopFocus: getBoolean(el, "loopFocus"), - selectionMode: getString<"single" | "multiple" | "extended">(el, "selectionMode", [ - "single", - "multiple", - "extended", - ]), - selectOnHighlight: getBoolean(el, "selectOnHighlight"), - deselectable: getBoolean(el, "deselectable"), - typeahead: getBoolean(el, "typeahead"), - onValueChange: (details: ValueChangeDetails) => { - const eventName = getString(el, "onValueChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { - value: details.value, - items: details.items, - id: el.id, - }); - } - const clientName = getString(el, "onValueChangeClient"); - if (clientName) { - el.dispatchEvent( - new CustomEvent(clientName, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); - } - }, } as Props); zag.hasGroups = hasGroups; zag.setOptions(allItems); zag.init(); this.listbox = zag; - this.handlers = []; - this.handleContentClick = (e: MouseEvent) => { - const btn = (e.target as HTMLElement).closest?.( - "[data-phx-push][data-phx-push-id]" - ) as HTMLElement | null; - if (btn && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - e.stopPropagation(); - e.preventDefault(); - this.pushEvent(btn.dataset.phxPush!, { id: btn.dataset.phxPushId }); - } + + const emitValue = (respondTo: RespondTo) => { + const value = zag.api.value; + emitResponse({ + respondTo, + canPushServer: canPush(), + pushEvent, + serverEventName: "listbox_value_response", + serverPayload: { id: el.id, value } as Record, + el, + domEventName: "listbox-value", + domDetail: { id: el.id, value } as Record, + }); }; - el.addEventListener("click", this.handleContentClick, true); + + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:listbox:set-value", (event) => { + zag.api.setValue(event.detail.value); + }); + + domRegistry.add("corex:listbox:value", (event) => { + emitValue(parseRespondTo(event.detail)); + }); + + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("listbox_set_value", (payload: { id?: string; value: string[] }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + zag.api.setValue(payload.value); + }); + + registry.add("listbox_value", (payload: { id?: string; respond_to?: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + emitValue(parseRespondTo(payload)); + }); }, updated(this: object & HookInterface & ListboxHookState) { @@ -119,28 +165,18 @@ const ListboxHook: Hook = { this.listbox.setOptions(newItems); this.listbox.render(); this.listbox.updateProps({ + ...listboxZagPropsBase(this.el, this.liveSocket, this.pushEvent.bind(this)), collection: this.listbox.getCollection(), - id: this.el.id, ...(controlled && valueList ? { value: valueList } : { defaultValue: defaultValueList ?? [] }), - disabled: getBoolean(this.el, "disabled"), - dir: getString(this.el, "dir", ["ltr", "rtl"]), - orientation: getString<"horizontal" | "vertical">(this.el, "orientation", [ - "horizontal", - "vertical", - ]), } as Partial>); } }, destroyed(this: object & HookInterface & ListboxHookState) { - if (this.handlers) { - for (const h of this.handlers) this.removeHandleEvent(h); - } - if (this.handleContentClick) { - this.el.removeEventListener("click", this.handleContentClick, true); - } + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.listbox?.destroy(); }, }; diff --git a/assets/hooks/marquee.ts b/assets/hooks/marquee.ts index 75754ff4..190e2b63 100644 --- a/assets/hooks/marquee.ts +++ b/assets/hooks/marquee.ts @@ -2,7 +2,8 @@ import type { Hook } from "phoenix_live_view"; import type { HookInterface, CallbackRef } from "phoenix_live_view/assets/js/types/view_hook"; import { Marquee } from "../components/marquee"; import type { Props } from "@zag-js/marquee"; -import { getString, getBoolean, getNumber, getDir } from "../lib/util"; +import { getBoolean, getDir, getNumber, getString } from "../lib/util"; +import { idMatches, readPayloadId } from "../lib/respond-to"; type MarqueeHookState = { marquee?: Marquee; @@ -12,33 +13,31 @@ type MarqueeHookState = { onTogglePause?: (event: Event) => void; }; +function readMarqueeProps(el: HTMLElement) { + return { + id: el.id, + translations: { root: getString(el, "ariaLabel") }, + duration: getNumber(el, "duration"), + side: getString<"start" | "end" | "top" | "bottom">(el, "side"), + speed: getNumber(el, "speed"), + spacing: getString(el, "spacing"), + autoFill: getBoolean(el, "autoFill"), + pauseOnInteraction: getBoolean(el, "pauseOnInteraction"), + defaultPaused: getBoolean(el, "defaultPaused"), + delay: getNumber(el, "delay"), + loopCount: getNumber(el, "loopCount"), + reverse: getBoolean(el, "reverse"), + dir: getDir(el), + }; +} + const MarqueeHook: Hook = { mounted(this: object & HookInterface & MarqueeHookState) { const el = this.el; const pushEvent = this.pushEvent.bind(this); - const ariaLabel = getString(el, "ariaLabel") ?? `Marquee: ${el.id}`; - const zag = new Marquee(el, { - id: el.id, - translations: { root: ariaLabel }, - duration: getNumber(el, "duration") ?? 20, - side: - getString<"start" | "end" | "top" | "bottom">(el, "side", [ - "start", - "end", - "top", - "bottom", - ]) ?? "end", - speed: getNumber(el, "speed") ?? 50, - spacing: getString(el, "spacing") ?? "1rem", - autoFill: getBoolean(el, "autoFill"), - pauseOnInteraction: getBoolean(el, "pauseOnInteraction"), - defaultPaused: getBoolean(el, "defaultPaused"), - delay: getNumber(el, "delay") ?? 0, - loopCount: getNumber(el, "loopCount") ?? 0, - reverse: getBoolean(el, "reverse"), - dir: getDir(el), + ...readMarqueeProps(el), onPauseChange: (details) => { const eventName = getString(el, "onPauseChange"); if (eventName && this.liveSocket.main.isConnected()) { @@ -79,72 +78,50 @@ const MarqueeHook: Hook = { } }, } as Props); + + zag.buildDom(); zag.init(); + this.marquee = zag; this.onPause = () => zag.api.pause(); this.onResume = () => zag.api.resume(); this.onTogglePause = () => zag.api.togglePause(); - el.addEventListener("phx:marquee:pause", this.onPause); - el.addEventListener("phx:marquee:resume", this.onResume); - el.addEventListener("phx:marquee:toggle-pause", this.onTogglePause); + el.addEventListener("corex:marquee:pause", this.onPause); + el.addEventListener("corex:marquee:resume", this.onResume); + el.addEventListener("corex:marquee:toggle-pause", this.onTogglePause); this.handlers = []; this.handlers.push( - this.handleEvent("marquee_pause", (payload: { marquee_id?: string }) => { - const targetId = payload.marquee_id; - if (targetId && el.id !== targetId && el.id !== `marquee:${targetId}`) return; + this.handleEvent("marquee_pause", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; zag.api.pause(); }) ); this.handlers.push( - this.handleEvent("marquee_resume", (payload: { marquee_id?: string }) => { - const targetId = payload.marquee_id; - if (targetId && el.id !== targetId && el.id !== `marquee:${targetId}`) return; + this.handleEvent("marquee_resume", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; zag.api.resume(); }) ); this.handlers.push( - this.handleEvent("marquee_toggle_pause", (payload: { marquee_id?: string }) => { - const targetId = payload.marquee_id; - if (targetId && el.id !== targetId && el.id !== `marquee:${targetId}`) return; + this.handleEvent("marquee_toggle_pause", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; zag.api.togglePause(); }) ); }, updated(this: object & HookInterface & MarqueeHookState) { - const ariaLabel = getString(this.el, "ariaLabel") ?? `Marquee: ${this.el.id}`; - - this.marquee?.updateProps({ - id: this.el.id, - translations: { root: ariaLabel }, - duration: getNumber(this.el, "duration") ?? 20, - side: - getString<"start" | "end" | "top" | "bottom">(this.el, "side", [ - "start", - "end", - "top", - "bottom", - ]) ?? "end", - speed: getNumber(this.el, "speed") ?? 50, - spacing: getString(this.el, "spacing") ?? "1rem", - autoFill: getBoolean(this.el, "autoFill"), - pauseOnInteraction: getBoolean(this.el, "pauseOnInteraction"), - defaultPaused: getBoolean(this.el, "defaultPaused"), - delay: getNumber(this.el, "delay") ?? 0, - loopCount: getNumber(this.el, "loopCount") ?? 0, - reverse: getBoolean(this.el, "reverse"), - dir: getDir(this.el), - } as Partial); + this.marquee?.updateProps(readMarqueeProps(this.el) as Partial); }, destroyed(this: object & HookInterface & MarqueeHookState) { - if (this.onPause) this.el.removeEventListener("phx:marquee:pause", this.onPause); - if (this.onResume) this.el.removeEventListener("phx:marquee:resume", this.onResume); + if (this.onPause) this.el.removeEventListener("corex:marquee:pause", this.onPause); + if (this.onResume) this.el.removeEventListener("corex:marquee:resume", this.onResume); if (this.onTogglePause) - this.el.removeEventListener("phx:marquee:toggle-pause", this.onTogglePause); + this.el.removeEventListener("corex:marquee:toggle-pause", this.onTogglePause); if (this.handlers) { for (const h of this.handlers) this.removeHandleEvent(h); } diff --git a/assets/hooks/menu.ts b/assets/hooks/menu.ts index 069798ad..cbcbf5e6 100644 --- a/assets/hooks/menu.ts +++ b/assets/hooks/menu.ts @@ -2,9 +2,10 @@ import type { Hook } from "phoenix_live_view"; import type { HookInterface, CallbackRef } from "phoenix_live_view/assets/js/types/view_hook"; import { Menu } from "../components/menu"; import type { SelectionDetails, OpenChangeDetails, Props } from "@zag-js/menu"; -import type { Direction } from "@zag-js/types"; -import { getString, getBoolean } from "../lib/util"; +import { getString, getBoolean, getDir, canPushEvent } from "../lib/util"; +import { performRedirect, readDomItemRedirect } from "../lib/redirect"; +import { readPositioningOptions } from "../lib/positioning"; type MenuHookState = { menu?: Menu; @@ -23,59 +24,51 @@ const MenuHook: Hook = { } const pushEvent = this.pushEvent.bind(this); - const getMain = () => this.liveSocket?.main; + const liveSocket = this.liveSocket; + + const buildOnSelect = () => (details: SelectionDetails) => { + if (getBoolean(el, "redirect") && details.value) { + const itemEl = el.querySelector( + `[data-scope="menu"][data-part="item"][data-value="${CSS.escape(details.value)}"]` + ); + performRedirect(readDomItemRedirect(itemEl, details.value), { liveSocket }); + } + + const eventName = getString(el, "onSelect"); + if (eventName && canPushEvent(liveSocket)) { + pushEvent(eventName, { + id: el.id, + value: details.value ?? null, + }); + } + + const eventNameClient = getString(el, "onSelectClient"); + if (eventNameClient) { + el.dispatchEvent( + new CustomEvent(eventNameClient, { + bubbles: true, + detail: { + id: el.id, + value: details.value ?? null, + }, + }) + ); + } + }; const menu = new Menu(el, { id: el.id.replace("menu:", ""), - defaultOpen: getBoolean(el, "defaultOpen"), closeOnSelect: getBoolean(el, "closeOnSelect"), loopFocus: getBoolean(el, "loopFocus"), typeahead: getBoolean(el, "typeahead"), composite: getBoolean(el, "composite"), defaultHighlightedValue: getString(el, "defaultHighlightedValue"), - dir: getString(el, "dir", ["ltr", "rtl"]), - onSelect: (details: SelectionDetails) => { - const redirect = getBoolean(el, "redirect"); - const itemEl = [ - ...el.querySelectorAll('[data-scope="menu"][data-part="item"]'), - ].find((node) => node.getAttribute("data-value") === details.value); - const itemRedirect = itemEl?.getAttribute("data-redirect"); - const itemNewTab = itemEl?.hasAttribute("data-new-tab"); - const main = getMain(); - const doRedirect = - redirect && details.value && (main?.isDead ?? true) && itemRedirect !== "false"; - if (doRedirect) { - if (itemNewTab) { - window.open(details.value, "_blank", "noopener,noreferrer"); - } else { - window.location.href = details.value; - } - } - const eventName = getString(el, "onSelect"); - if (eventName && main && !main.isDead && main.isConnected()) { - pushEvent(eventName, { - id: el.id, - value: details.value ?? null, - }); - } - - const eventNameClient = getString(el, "onSelectClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { - id: el.id, - value: details.value ?? null, - }, - }) - ); - } - }, + dir: getDir(el), + positioning: readPositioningOptions(el), + onSelect: buildOnSelect(), onOpenChange: (details: OpenChangeDetails) => { - const main = getMain(); const eventName = getString(el, "onOpenChange"); - if (eventName && main && !main.isDead && main.isConnected()) { + if (eventName && canPushEvent(liveSocket)) { pushEvent(eventName, { id: el.id, open: details.open ?? false, @@ -111,49 +104,13 @@ const MenuHook: Hook = { const nestedMenuId = `${nestedId}-${index}`; const nestedMenu = new Menu(nestedEl, { id: nestedMenuId, - dir: getString(nestedEl, "dir", ["ltr", "rtl"]), + dir: getDir(nestedEl), closeOnSelect: getBoolean(nestedEl, "closeOnSelect"), loopFocus: getBoolean(nestedEl, "loopFocus"), typeahead: getBoolean(nestedEl, "typeahead"), composite: getBoolean(nestedEl, "composite"), - onSelect: (details: SelectionDetails) => { - const redirect = getBoolean(el, "redirect"); - const itemEl = [ - ...el.querySelectorAll('[data-scope="menu"][data-part="item"]'), - ].find((node) => node.getAttribute("data-value") === details.value); - const itemRedirect = itemEl?.getAttribute("data-redirect"); - const itemNewTab = itemEl?.hasAttribute("data-new-tab"); - const main = getMain(); - const doRedirect = - redirect && details.value && (main?.isDead ?? true) && itemRedirect !== "false"; - if (doRedirect) { - if (itemNewTab) { - window.open(details.value, "_blank", "noopener,noreferrer"); - } else { - window.location.href = details.value; - } - } - const eventName = getString(el, "onSelect"); - if (eventName && main && !main.isDead && main.isConnected()) { - pushEvent(eventName, { - id: el.id, - value: details.value ?? null, - }); - } - - const eventNameClient = getString(el, "onSelectClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { - id: el.id, - value: details.value ?? null, - }, - }) - ); - } - }, + positioning: readPositioningOptions(nestedEl), + onSelect: buildOnSelect(), }); nestedMenu.init(); @@ -179,7 +136,7 @@ const MenuHook: Hook = { const { open } = (event as CustomEvent<{ open: boolean }>).detail; if (menu.api.open !== open) menu.api.setOpen(open); }; - el.addEventListener("phx:menu:set-open", this.onSetOpen); + el.addEventListener("corex:menu:set-open", this.onSetOpen); this.handlers = []; @@ -206,15 +163,13 @@ const MenuHook: Hook = { this.menu?.updateProps({ id: this.el.id, - ...(getBoolean(this.el, "controlled") - ? { open: getBoolean(this.el, "open") } - : { defaultOpen: getBoolean(this.el, "defaultOpen") }), closeOnSelect: getBoolean(this.el, "closeOnSelect"), loopFocus: getBoolean(this.el, "loopFocus"), typeahead: getBoolean(this.el, "typeahead"), composite: getBoolean(this.el, "composite"), defaultHighlightedValue: getString(this.el, "defaultHighlightedValue"), - dir: getString(this.el, "dir", ["ltr", "rtl"]), + dir: getDir(this.el), + positioning: readPositioningOptions(this.el), } as Props); }, @@ -222,7 +177,7 @@ const MenuHook: Hook = { if (this.el.hasAttribute("data-nested")) return; if (this.onSetOpen) { - this.el.removeEventListener("phx:menu:set-open", this.onSetOpen); + this.el.removeEventListener("corex:menu:set-open", this.onSetOpen); } if (this.handlers) { diff --git a/assets/hooks/number-input.ts b/assets/hooks/number-input.ts index fac185ae..45c5b497 100644 --- a/assets/hooks/number-input.ts +++ b/assets/hooks/number-input.ts @@ -1,25 +1,23 @@ 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 { NumberInput } from "../components/number-input"; import type { Props, ValueChangeDetails } from "@zag-js/number-input"; -import { getString, getBoolean, getNumber, canPushEvent } from "../lib/util"; +import { getString, getBoolean, getNumber, canPushEvent, getDir } from "../lib/util"; +import { notifyChange } from "../lib/respond-to"; type NumberInputHookState = { numberInput?: NumberInput; - handlers?: Array; }; const NumberInputHook: Hook = { mounted(this: object & HookInterface & NumberInputHookState) { const el = this.el; - const valueStr = getString(el, "value"); + const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const defaultValueStr = getString(el, "defaultValue"); - const controlled = getBoolean(el, "controlled"); const zag = new NumberInput(el, { id: el.id, - ...(controlled && valueStr !== undefined - ? { value: valueStr } - : { defaultValue: defaultValueStr }), + defaultValue: defaultValueStr, min: getNumber(el, "min"), max: getNumber(el, "max"), step: getNumber(el, "step"), @@ -30,6 +28,7 @@ const NumberInputHook: Hook = { allowMouseWheel: getBoolean(el, "allowMouseWheel"), name: getString(el, "name"), form: getString(el, "form"), + dir: getDir(el), onValueChange: (details: ValueChangeDetails) => { if (details.value !== undefined) { const valueInput = el.querySelector( @@ -37,42 +36,34 @@ const NumberInputHook: Hook = { ); if (valueInput) { valueInput.value = details.value ?? ""; + valueInput.dispatchEvent(new Event("input", { bubbles: true })); + valueInput.dispatchEvent(new Event("change", { bubbles: true })); } } - const eventName = getString(el, "onValueChange"); - if (eventName && canPushEvent(this.liveSocket)) { - this.pushEvent(eventName, { + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { + id: el.id, value: details.value, valueAsNumber: details.valueAsNumber, - id: el.id, - }); - } - const clientName = getString(el, "onValueChangeClient"); - if (clientName) { - el.dispatchEvent( - new CustomEvent(clientName, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); - } + }, + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); }, } as Props); zag.init(); this.numberInput = zag; - this.handlers = []; }, updated(this: object & HookInterface & NumberInputHookState) { - const valueStr = getString(this.el, "value"); - const controlled = getBoolean(this.el, "controlled"); const defaultValueStr = getString(this.el, "defaultValue"); this.numberInput?.updateProps({ id: this.el.id, - ...(controlled && valueStr !== undefined - ? { value: valueStr } - : { defaultValue: defaultValueStr }), + defaultValue: defaultValueStr, min: getNumber(this.el, "min"), max: getNumber(this.el, "max"), step: getNumber(this.el, "step"), @@ -82,13 +73,11 @@ const NumberInputHook: Hook = { required: getBoolean(this.el, "required"), name: getString(this.el, "name"), form: getString(this.el, "form"), + dir: getDir(this.el), } as Partial); }, destroyed(this: object & HookInterface & NumberInputHookState) { - if (this.handlers) { - for (const h of this.handlers) this.removeHandleEvent(h); - } this.numberInput?.destroy(); }, }; diff --git a/assets/hooks/password-input.ts b/assets/hooks/password-input.ts index e9c733ed..53799e07 100644 --- a/assets/hooks/password-input.ts +++ b/assets/hooks/password-input.ts @@ -2,7 +2,8 @@ import type { Hook } from "phoenix_live_view"; import type { HookInterface, CallbackRef } from "phoenix_live_view/assets/js/types/view_hook"; import { PasswordInput } from "../components/password-input"; import type { Props, VisibilityChangeDetails } from "@zag-js/password-input"; -import { getString, getBoolean, getDir } from "../lib/util"; +import { getString, getBoolean, getDir, canPushEvent } from "../lib/util"; +import { notifyChange } from "../lib/respond-to"; type PasswordInputHookState = { passwordInput?: PasswordInput; @@ -12,11 +13,11 @@ type PasswordInputHookState = { const PasswordInputHook: Hook = { mounted(this: object & HookInterface & PasswordInputHookState) { const el = this.el; + const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const zag = new PasswordInput(el, { id: el.id, - ...(getBoolean(el, "controlledVisible") - ? { visible: getBoolean(el, "visible") } - : { defaultVisible: getBoolean(el, "defaultVisible") }), + defaultVisible: getBoolean(el, "defaultVisible"), disabled: getBoolean(el, "disabled"), invalid: getBoolean(el, "invalid"), readOnly: getBoolean(el, "readOnly"), @@ -24,24 +25,16 @@ const PasswordInputHook: Hook = { ignorePasswordManagers: getBoolean(el, "ignorePasswordManagers"), name: getString(el, "name"), dir: getDir(el), - autoComplete: getString<"current-password" | "new-password">(el, "autoComplete", [ - "current-password", - "new-password", - ]), + autoComplete: getString<"current-password" | "new-password">(el, "autoComplete"), onVisibilityChange: (details: VisibilityChangeDetails) => { - const eventName = getString(el, "onVisibilityChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { visible: details.visible, id: el.id }); - } - const clientName = getString(el, "onVisibilityChangeClient"); - if (clientName) { - el.dispatchEvent( - new CustomEvent(clientName, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, visible: details.visible } as Record, + serverEventName: getString(el, "onVisibilityChange"), + clientEventName: getString(el, "onVisibilityChangeClient"), + }); }, } as Props); zag.init(); @@ -52,9 +45,6 @@ const PasswordInputHook: Hook = { updated(this: object & HookInterface & PasswordInputHookState) { this.passwordInput?.updateProps({ id: this.el.id, - ...(getBoolean(this.el, "controlledVisible") - ? { visible: getBoolean(this.el, "visible") } - : {}), disabled: getBoolean(this.el, "disabled"), invalid: getBoolean(this.el, "invalid"), readOnly: getBoolean(this.el, "readOnly"), diff --git a/assets/hooks/pin-input.ts b/assets/hooks/pin-input.ts index ebdea546..dc47b319 100644 --- a/assets/hooks/pin-input.ts +++ b/assets/hooks/pin-input.ts @@ -1,9 +1,18 @@ 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 { PinInput } from "../components/pin-input"; import type { Props, ValueChangeDetails } from "@zag-js/pin-input"; -import type { Direction } from "@zag-js/types"; -import { getString, getBoolean, getStringList, getNumber } from "../lib/util"; +import { getString, getBoolean, getNumber, getDir, canPushEvent } from "../lib/util"; +import { + notifyChange, + emitResponse, + idMatches, + readPayloadId, + parseRespondTo, + type RespondTo, +} from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; function parseValueWithEmpties(raw: string): string[] { return raw.split(",").map((v) => v.trim()); @@ -15,26 +24,151 @@ function padToCount(arr: string[], count: number): string[] { return copy.slice(0, count); } +function readDefaultValueList(el: HTMLElement, count: number): string[] { + const raw = el.dataset.defaultValue; + if (raw === undefined || raw === "") { + return []; + } + return padToCount(parseValueWithEmpties(raw), count); +} + +function buildMachineProps( + el: HTMLElement, + pushEvent: (name: string, payload: Record) => void, + canPush: () => boolean +): Props { + const count = getNumber(el, "count"); + + return { + id: el.id, + count, + defaultValue: readDefaultValueList(el, count ?? 0), + disabled: getBoolean(el, "disabled"), + invalid: getBoolean(el, "invalid"), + required: getBoolean(el, "required"), + readOnly: getBoolean(el, "readOnly"), + mask: getBoolean(el, "mask"), + otp: getBoolean(el, "otp"), + blurOnComplete: getBoolean(el, "blurOnComplete"), + selectOnFocus: getBoolean(el, "selectOnFocus"), + name: getString(el, "name"), + form: getString(el, "form"), + dir: getDir(el), + type: getString<"alphanumeric" | "numeric" | "alphabetic">(el, "type"), + placeholder: getString(el, "placeholder"), + onValueChange: (details: ValueChangeDetails) => { + const hiddenInput = el.querySelector( + '[data-scope="pin-input"][data-part="hidden-input"]' + ); + if (hiddenInput) { + hiddenInput.value = details.valueAsString; + hiddenInput.dispatchEvent(new Event("input", { bubbles: true })); + hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); + } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent: pushEvent, + payload: { + id: el.id, + value: details.value, + valueAsString: details.valueAsString, + }, + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); + }, + onValueComplete: (details: ValueChangeDetails) => { + notifyChange({ + el, + canPushServer: canPush(), + pushEvent: pushEvent, + payload: { + id: el.id, + value: details.value, + valueAsString: details.valueAsString, + }, + serverEventName: getString(el, "onValueComplete"), + clientEventName: getString(el, "onValueCompleteClient"), + }); + }, + } as Props; +} + type PinInputHookState = { pinInput?: PinInput; - handlers?: Array; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; }; const PinInputHook: Hook = { mounted(this: object & HookInterface & PinInputHookState) { const el = this.el; - const count = getNumber(el, "count") ?? 4; - const rawValue = el.dataset.value; - const valueList = - rawValue != null ? padToCount(parseValueWithEmpties(rawValue), count) : undefined; - const defaultValueList = getStringList(el, "defaultValue"); - const controlled = getBoolean(el, "controlled"); - const zag = new PinInput(el, { + const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); + + const zag = new PinInput(el, buildMachineProps(el, pushEvent, canPush)); + zag.init(); + this.pinInput = zag; + + const emitValue = (respondTo: RespondTo) => { + const api = zag.api; + const value = api.value; + const valueAsString = api.valueAsString; + emitResponse({ + respondTo, + canPushServer: canPush(), + pushEvent: pushEvent, + serverEventName: "pin_input_value_response", + serverPayload: { id: el.id, value, valueAsString } as Record, + el, + domEventName: "pin-input-value", + domDetail: { id: el.id, value, valueAsString } as Record, + }); + }; + + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:pin-input:set-value", (event) => { + const v = event.detail?.value; + if (Array.isArray(v)) zag.api.setValue(v); + }); + + domRegistry.add("corex:pin-input:clear", () => { + zag.api.clearValue(); + }); + + domRegistry.add("corex:pin-input:value", (event) => { + emitValue(parseRespondTo(event.detail)); + }); + + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("pin_input_set_value", (payload: { id?: string; value: string[] }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (Array.isArray(payload.value)) zag.api.setValue(payload.value); + }); + + registry.add("pin_input_clear", (payload: { id?: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + zag.api.clearValue(); + }); + + registry.add("pin_input_value", (payload: { id?: string; respond_to?: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + emitValue(parseRespondTo(payload)); + }); + }, + + updated(this: object & HookInterface & PinInputHookState) { + const el = this.el; + const count = getNumber(el, "count"); + this.pinInput?.updateProps({ id: el.id, count, - ...(controlled && valueList - ? { value: valueList } - : { defaultValue: defaultValueList ?? [] }), + defaultValue: readDefaultValueList(el, count ?? 0), disabled: getBoolean(el, "disabled"), invalid: getBoolean(el, "invalid"), required: getBoolean(el, "required"), @@ -45,79 +179,15 @@ const PinInputHook: Hook = { selectOnFocus: getBoolean(el, "selectOnFocus"), name: getString(el, "name"), form: getString(el, "form"), - dir: getString(el, "dir", ["ltr", "rtl"]), - type: getString<"alphanumeric" | "numeric" | "alphabetic">(el, "type", [ - "alphanumeric", - "numeric", - "alphabetic", - ]), + dir: getDir(el), + type: getString<"alphanumeric" | "numeric" | "alphabetic">(el, "type"), placeholder: getString(el, "placeholder"), - onValueChange: (details: ValueChangeDetails) => { - const hiddenInput = el.querySelector( - '[data-scope="pin-input"][data-part="hidden-input"]' - ); - if (hiddenInput) { - hiddenInput.value = details.valueAsString; - hiddenInput.dispatchEvent(new Event("input", { bubbles: true })); - hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); - } - const eventName = getString(el, "onValueChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { - value: details.value, - valueAsString: details.valueAsString, - id: el.id, - }); - } - const clientName = getString(el, "onValueChangeClient"); - if (clientName) { - el.dispatchEvent( - new CustomEvent(clientName, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); - } - }, - onValueComplete: (details: ValueChangeDetails) => { - const eventName = getString(el, "onValueComplete"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { - value: details.value, - valueAsString: details.valueAsString, - id: el.id, - }); - } - }, - } as Props); - zag.init(); - this.pinInput = zag; - this.handlers = []; - }, - - updated(this: object & HookInterface & PinInputHookState) { - const count = getNumber(this.el, "count") ?? this.pinInput?.api.count ?? 4; - const rawValue = this.el.dataset.value; - const valueList = - rawValue != null ? padToCount(parseValueWithEmpties(rawValue), count) : undefined; - const controlled = getBoolean(this.el, "controlled"); - this.pinInput?.updateProps({ - id: this.el.id, - count, - ...(controlled && valueList ? { value: valueList } : {}), - disabled: getBoolean(this.el, "disabled"), - invalid: getBoolean(this.el, "invalid"), - required: getBoolean(this.el, "required"), - readOnly: getBoolean(this.el, "readOnly"), - name: getString(this.el, "name"), - form: getString(this.el, "form"), } as Partial); }, destroyed(this: object & HookInterface & PinInputHookState) { - if (this.handlers) { - for (const h of this.handlers) this.removeHandleEvent(h); - } + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.pinInput?.destroy(); }, }; diff --git a/assets/hooks/radio-group.ts b/assets/hooks/radio-group.ts index f3a2fb89..658c6d2f 100644 --- a/assets/hooks/radio-group.ts +++ b/assets/hooks/radio-group.ts @@ -1,84 +1,76 @@ 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 { RadioGroup } from "../components/radio-group"; import type { Props, ValueChangeDetails } from "@zag-js/radio-group"; -import type { Direction } from "@zag-js/types"; -import { getString, getBoolean } from "../lib/util"; +import { getString, getBoolean, getDir, canPushEvent } from "../lib/util"; +import { readStringControlledZagProps, readStringControlledZagUpdate } from "../lib/read-props"; +import { notifyChange } from "../lib/respond-to"; type RadioGroupHookState = { radioGroup?: RadioGroup; - handlers?: Array; }; +function valueChangePayload(el: HTMLElement, details: ValueChangeDetails): Record { + return { + id: el.id, + value: details.value, + }; +} + const RadioGroupHook: Hook = { mounted(this: object & HookInterface & RadioGroupHookState) { const el = this.el; - const value = getString(el, "value"); - const defaultValue = getString(el, "defaultValue"); - const controlled = getBoolean(el, "controlled"); + const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const zag = new RadioGroup(el, { id: el.id, - ...(controlled && value !== undefined - ? { value: value ?? null } - : { defaultValue: defaultValue ?? null }), + ...readStringControlledZagProps(el, "value", "defaultValue"), name: getString(el, "name"), form: getString(el, "form"), disabled: getBoolean(el, "disabled"), invalid: getBoolean(el, "invalid"), required: getBoolean(el, "required"), readOnly: getBoolean(el, "readOnly"), - dir: getString(el, "dir", ["ltr", "rtl"]), - orientation: getString<"horizontal" | "vertical">(el, "orientation", [ - "horizontal", - "vertical", - ]), + dir: getDir(el), + orientation: getString<"horizontal" | "vertical">(el, "orientation"), onValueChange: (details: ValueChangeDetails) => { - const eventName = getString(el, "onValueChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { - value: details.value, - id: el.id, - }); - } - const clientName = getString(el, "onValueChangeClient"); - if (clientName) { - el.dispatchEvent( - new CustomEvent(clientName, { - bubbles: true, - detail: { value: details, id: el.id }, - }) - ); + const checked = el.querySelector( + '[data-scope="radio-group"][data-part="item-hidden-input"]:checked' + ); + if (checked) { + checked.dispatchEvent(new Event("input", { bubbles: true })); + checked.dispatchEvent(new Event("change", { bubbles: true })); } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: valueChangePayload(el, details), + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); }, } as Props); zag.init(); this.radioGroup = zag; - this.handlers = []; }, updated(this: object & HookInterface & RadioGroupHookState) { - const value = getString(this.el, "value"); - const controlled = getBoolean(this.el, "controlled"); this.radioGroup?.updateProps({ id: this.el.id, - ...(controlled && value !== undefined ? { value: value ?? null } : {}), + ...readStringControlledZagUpdate(this.el, "value", "defaultValue"), name: getString(this.el, "name"), form: getString(this.el, "form"), disabled: getBoolean(this.el, "disabled"), invalid: getBoolean(this.el, "invalid"), required: getBoolean(this.el, "required"), readOnly: getBoolean(this.el, "readOnly"), - orientation: getString<"horizontal" | "vertical">(this.el, "orientation", [ - "horizontal", - "vertical", - ]), + orientation: getString<"horizontal" | "vertical">(this.el, "orientation"), + dir: getDir(this.el), } as Partial); }, destroyed(this: object & HookInterface & RadioGroupHookState) { - if (this.handlers) { - for (const h of this.handlers) this.removeHandleEvent(h); - } this.radioGroup?.destroy(); }, }; diff --git a/assets/hooks/select.ts b/assets/hooks/select.ts index ab4669b4..968d7f80 100644 --- a/assets/hooks/select.ts +++ b/assets/hooks/select.ts @@ -3,10 +3,13 @@ import type { HookInterface, CallbackRef } from "phoenix_live_view/assets/js/typ import { collection } from "@zag-js/select"; import { Select } from "../components/select"; import type { Props, ValueChangeDetails } from "@zag-js/select"; -import type { Direction } from "@zag-js/types"; -import type { PositioningOptions } from "@zag-js/popper"; -import { getString, getBoolean, getStringList, canPushEvent } from "../lib/util"; +import { getString, getBoolean, getStringList, canPushEvent, getDir } from "../lib/util"; +import { readPositioningOptions } from "../lib/positioning"; +import { performRedirect, readDomItemRedirect } from "../lib/redirect"; +import { idMatches, readPayloadId, notifyChange } from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; type SelectItem = { id?: string; @@ -38,27 +41,22 @@ type SelectHookState = { select?: Select; handlers?: Array; wasFocused?: boolean; + lastItemsJson?: string; + domRegistry?: ReturnType; + handleRegistry?: ReturnType; }; -function snakeToCamel(str: string): string { - return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); -} - -function transformPositioningOptions(obj: Record): PositioningOptions { - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - const camelKey = snakeToCamel(key); - result[camelKey] = value; - } - return result as PositioningOptions; -} - const SelectHook: Hook = { mounted(this: object & HookInterface & SelectHookState) { const el = this.el; + const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const allItems = JSON.parse(el.dataset.items || "[]") as SelectItem[]; - const hasGroups = allItems.some((item: SelectItem) => item.group !== undefined); + const hasGroups = allItems.some((item: SelectItem) => Boolean(item.group)); + const initialCollection = buildCollection(allItems, hasGroups); + const redirectOn = getBoolean(el, "redirect"); + const selectComponent = new Select(el, { id: el.id, collection: initialCollection, @@ -67,50 +65,26 @@ const SelectHook: Hook = { : { defaultValue: getStringList(el, "defaultValue") }), disabled: getBoolean(el, "disabled"), closeOnSelect: getBoolean(el, "closeOnSelect"), - dir: getString(el, "dir", ["ltr", "rtl"]), + dir: getDir(el), loopFocus: getBoolean(el, "loopFocus"), - multiple: getBoolean(el, "multiple"), + multiple: redirectOn ? false : getBoolean(el, "multiple"), invalid: getBoolean(el, "invalid"), name: getString(el, "name"), form: getString(el, "form"), readOnly: getBoolean(el, "readOnly"), required: getBoolean(el, "required"), - positioning: (() => { - const positioningJson = el.dataset.positioning; - if (positioningJson) { - try { - const parsed = JSON.parse(positioningJson); - return transformPositioningOptions(parsed); - } catch { - return undefined; - } - } - return undefined; - })(), + deselectable: getBoolean(el, "deselectable"), + positioning: readPositioningOptions(el), onValueChange: (details: ValueChangeDetails) => { - const redirect = getBoolean(el, "redirect"); const firstValue = details.value.length > 0 ? String(details.value[0]) : null; - const firstItem = details.items?.length ? details.items[0] : null; - const itemRedirect = - firstItem && - typeof firstItem === "object" && - firstItem !== null && - "redirect" in firstItem - ? (firstItem as { redirect?: boolean }).redirect - : undefined; - const itemNewTab = - firstItem && typeof firstItem === "object" && firstItem !== null && "new_tab" in firstItem - ? (firstItem as { new_tab?: boolean }).new_tab - : undefined; - const doRedirect = - redirect && firstValue && this.liveSocket.main.isDead && itemRedirect !== false; - const openInNewTab = itemNewTab === true; - if (doRedirect) { - if (openInNewTab) { - window.open(firstValue, "_blank", "noopener,noreferrer"); - } else { - window.location.href = firstValue; - } + + if (getBoolean(el, "redirect") && firstValue) { + const itemEl = el.querySelector( + `[data-scope="select"][data-part="item"][data-value="${CSS.escape(firstValue)}"]` + ); + performRedirect(readDomItemRedirect(itemEl, firstValue), { + liveSocket: this.liveSocket, + }); } const valueInput = el.querySelector( @@ -123,55 +97,95 @@ const SelectHook: Hook = { : details.value.length === 1 ? String(details.value[0]) : details.value.map(String).join(","); + valueInput.dispatchEvent(new Event("input", { bubbles: true })); valueInput.dispatchEvent(new Event("change", { bubbles: true })); } - const payload: Record = { - value: details.value, - items: details.items, - id: el.id, - }; - - const clientEventName = getString(el, "onValueChangeClient"); - if (clientEventName) { - el.dispatchEvent(new CustomEvent(clientEventName, { bubbles: true, detail: payload })); - } - - const serverEventName = getString(el, "onValueChange"); - if (serverEventName && canPushEvent(this.liveSocket)) { - this.pushEvent(serverEventName, payload); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { + id: el.id, + value: details.value, + items: details.items, + } as Record, + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); }, } as Props); + selectComponent.hasGroups = hasGroups; selectComponent.setOptions(allItems); selectComponent.init(); this.select = selectComponent; this.handlers = []; + this.lastItemsJson = el.dataset.items || "[]"; + + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:select:set-value", (event) => { + selectComponent.api.setValue(event.detail.value); + }); + + domRegistry.add>("corex:select:set-open", (event) => { + selectComponent.api.setOpen(event.detail.open); + }); + + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("select_set_value", (payload: { id?: string; value: string[] }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + selectComponent.api.setValue(payload.value); + }); + + registry.add("select_set_open", (payload: { id?: string; open?: boolean }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (typeof payload.open !== "boolean") return; + selectComponent.api.setOpen(payload.open); + }); }, updated(this: object & HookInterface & SelectHookState) { - const newItems = JSON.parse(this.el.dataset.items || "[]") as SelectItem[]; - const hasGroups = newItems.some((item: SelectItem) => item.group !== undefined); + const itemsJson = this.el.dataset.items || "[]"; + const itemsUnchanged = itemsJson === this.lastItemsJson; + const redirectOn = getBoolean(this.el, "redirect"); + + const nextProps: Partial = { + id: this.el.id, + ...(getBoolean(this.el, "controlled") + ? { value: getStringList(this.el, "value") } + : { defaultValue: getStringList(this.el, "defaultValue") }), + name: getString(this.el, "name"), + form: getString(this.el, "form"), + disabled: getBoolean(this.el, "disabled"), + multiple: redirectOn ? false : getBoolean(this.el, "multiple"), + dir: getDir(this.el), + invalid: getBoolean(this.el, "invalid"), + required: getBoolean(this.el, "required"), + readOnly: getBoolean(this.el, "readOnly"), + positioning: readPositioningOptions(this.el), + }; + + if (this.select && itemsUnchanged) { + this.select.updateProps(nextProps); + return; + } + + this.lastItemsJson = itemsJson; + const newItems = JSON.parse(itemsJson) as SelectItem[]; + const hasGroups = newItems.some((item: SelectItem) => Boolean(item.group)); if (this.select) { this.select.hasGroups = hasGroups; this.select.setOptions(newItems); this.select.updateProps({ + ...nextProps, collection: buildCollection(newItems, hasGroups), - id: this.el.id, - ...(getBoolean(this.el, "controlled") - ? { value: getStringList(this.el, "value") } - : { defaultValue: getStringList(this.el, "defaultValue") }), - name: getString(this.el, "name"), - form: getString(this.el, "form"), - disabled: getBoolean(this.el, "disabled"), - multiple: getBoolean(this.el, "multiple"), - dir: getString(this.el, "dir", ["ltr", "rtl"]), - invalid: getBoolean(this.el, "invalid"), - required: getBoolean(this.el, "required"), - readOnly: getBoolean(this.el, "readOnly"), } as Props); } }, @@ -183,6 +197,8 @@ const SelectHook: Hook = { } } + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.select?.destroy(); }, }; diff --git a/assets/hooks/signature-pad.ts b/assets/hooks/signature-pad.ts index f754e343..f945ac8a 100644 --- a/assets/hooks/signature-pad.ts +++ b/assets/hooks/signature-pad.ts @@ -3,61 +3,109 @@ import type { HookInterface, CallbackRef } from "phoenix_live_view/assets/js/typ import { SignaturePad } from "../components/signature-pad"; import type { Props } from "@zag-js/signature-pad"; -import { getString, getBoolean, getNumber } from "../lib/util"; - -function getPaths(el: HTMLElement, attr: string): unknown[] { - const value = el.dataset[attr]; - if (!value) return []; - try { - return JSON.parse(value); - } catch { - return []; - } +import { getBoolean, getNumber, getString } from "../lib/util"; +import { idMatches, readPayloadId } from "../lib/respond-to"; + +const PHX_HAS_FOCUSED = "phx-has-focused"; + +function parsePathsFromDataset(el: HTMLElement, key: "defaultPaths" | "paths"): string[] { + const raw = el.dataset[key]; + if (!raw) return []; + return raw + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); } -function buildDrawingOptions(el: HTMLElement): Props["drawing"] { - return { - fill: getString(el, "drawingFill") || "black", - size: getNumber(el, "drawingSize") ?? 2, +function reapplyLiveViewValueInputUsage(input: HTMLInputElement) { + const p = input as HTMLInputElement & { phxPrivate?: Record }; + if (!p.phxPrivate) p.phxPrivate = {}; + p.phxPrivate[PHX_HAS_FOCUSED] = true; +} + +function buildDrawingOptions(el: HTMLElement): NonNullable { + const o: Record = { + fill: getString(el, "drawingFill"), + size: getNumber(el, "drawingSize"), simulatePressure: getBoolean(el, "drawingSimulatePressure"), - smoothing: getNumber(el, "drawingSmoothing") ?? 0.5, - thinning: getNumber(el, "drawingThinning") ?? 0.7, - streamline: getNumber(el, "drawingStreamline") ?? 0.65, + smoothing: getNumber(el, "drawingSmoothing"), + thinning: getNumber(el, "drawingThinning"), + streamline: getNumber(el, "drawingStreamline"), }; + const easing = getString(el, "drawingEasing"); + if (easing) o.easing = easing; + return o as NonNullable; +} + +function queueFormBubblingInputForPhoenix( + el: HTMLElement, + getValue: () => string, + opts: { onPadTouched: () => void } +): void { + queueMicrotask(() => { + const input = el.querySelector( + '[data-scope="signature-pad"][data-part="hidden-input"]' + ); + if (!input) { + return; + } + const v = getValue(); + if (String(input.value) !== String(v)) { + input.value = v; + } + opts.onPadTouched(); + reapplyLiveViewValueInputUsage(input); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); } type SignaturePadHookState = { signaturePad?: SignaturePad; handlers?: Array; onClear?: (event: Event) => void; + padTouched: boolean; }; const SignaturePadHook: Hook = { mounted(this: object & HookInterface & SignaturePadHookState) { const el = this.el; + const hook = this as object & SignaturePadHookState; const pushEvent = this.pushEvent.bind(this); + hook.padTouched = false; + const markTouched = () => { + hook.padTouched = true; + }; - const controlled = getBoolean(el, "controlled"); - const paths = getPaths(el, "paths"); - const defaultPaths = getPaths(el, "defaultPaths"); + const defaultPaths = parsePathsFromDataset(el, "defaultPaths"); + { + const input = el.querySelector( + '[data-scope="signature-pad"][data-part="hidden-input"]' + ); + if (String(input?.value ?? "") !== "" || defaultPaths.length > 0) { + hook.padTouched = true; + queueMicrotask(() => { + const i = el.querySelector( + '[data-scope="signature-pad"][data-part="hidden-input"]' + ); + if (i) reapplyLiveViewValueInputUsage(i); + }); + } + } const signaturePad = new SignaturePad(el, { id: el.id, name: getString(el, "name"), - ...(controlled && paths.length > 0 ? { paths: paths } : undefined), - ...(!controlled && defaultPaths.length > 0 ? { defaultPaths: defaultPaths } : undefined), + ...(defaultPaths.length > 0 ? { defaultPaths } : {}), drawing: buildDrawingOptions(el), onDrawEnd: (details) => { signaturePad.setPaths(details.paths); - const hiddenInput = el.querySelector( - '[data-scope="signature-pad"][data-part="hidden-input"]' + queueFormBubblingInputForPhoenix( + el, + () => (details.paths.length > 0 ? details.paths.join("\n") : ""), + { onPadTouched: markTouched } ); - if (hiddenInput) { - hiddenInput.value = details.paths.length > 0 ? JSON.stringify(details.paths) : ""; - hiddenInput.dispatchEvent(new Event("input", { bubbles: true })); - hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); - } details.getDataUrl("image/png").then((url) => { signaturePad.imageURL = url; @@ -89,47 +137,55 @@ const SignaturePadHook: Hook = { } as Props); signaturePad.init(); this.signaturePad = signaturePad; - this.onClear = (event: Event) => { const { id: targetId } = (event as CustomEvent<{ id: string }>).detail; if (targetId && targetId !== el.id) return; signaturePad.api.clear(); + queueFormBubblingInputForPhoenix(el, () => "", { onPadTouched: markTouched }); }; - el.addEventListener("phx:signature-pad:clear", this.onClear); + el.addEventListener("corex:signature-pad:clear", this.onClear); this.handlers = []; this.handlers.push( - this.handleEvent("signature_pad_clear", (payload: { signature_pad_id?: string }) => { - const targetId = payload.signature_pad_id; - if (targetId && targetId !== el.id) return; + this.handleEvent("signature_pad_clear", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; signaturePad.api.clear(); + queueFormBubblingInputForPhoenix(el, () => "", { onPadTouched: markTouched }); }) ); }, updated(this: object & HookInterface & SignaturePadHookState) { - const controlled = getBoolean(this.el, "controlled"); - const paths = getPaths(this.el, "paths"); - const defaultPaths = getPaths(this.el, "defaultPaths"); - const name = getString(this.el, "name"); + const el = this.el; + const name = getString(el, "name"); if (name) { this.signaturePad?.setName(name); } this.signaturePad?.updateProps({ - id: this.el.id, + id: el.id, name: name, - ...(controlled && paths.length > 0 ? { paths: paths } : {}), - ...(!controlled && defaultPaths.length > 0 ? { defaultPaths: defaultPaths } : {}), - drawing: buildDrawingOptions(this.el), - } as Props); + drawing: buildDrawingOptions(el), + } as Partial); + + if (!this.padTouched) { + return; + } + queueMicrotask(() => { + const input = this.el.querySelector( + '[data-scope="signature-pad"][data-part="hidden-input"]' + ); + if (input) { + reapplyLiveViewValueInputUsage(input); + } + }); }, destroyed(this: object & HookInterface & SignaturePadHookState) { if (this.onClear) { - this.el.removeEventListener("phx:signature-pad:clear", this.onClear); + this.el.removeEventListener("corex:signature-pad:clear", this.onClear); } if (this.handlers) { diff --git a/assets/hooks/switch.ts b/assets/hooks/switch.ts index cc243e6d..406249ac 100644 --- a/assets/hooks/switch.ts +++ b/assets/hooks/switch.ts @@ -1,22 +1,35 @@ 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 { Switch } from "../components/switch"; import type { CheckedChangeDetails } from "@zag-js/switch"; -import type { Direction } from "@zag-js/types"; -import { getString, getBoolean, canPushEvent } from "../lib/util"; +import { getString, getBoolean, getDir, canPushEvent } from "../lib/util"; +import { idMatches, notifyChange, readPayloadId, readPayloadChecked } from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; type SwitchHookState = { zagSwitch?: Switch; - handlers?: Array; - onSetChecked?: (event: Event) => void; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; wasFocused?: boolean; }; +function checkedChangePayload( + el: HTMLElement, + details: CheckedChangeDetails +): Record { + return { + id: el.id, + checked: details.checked, + }; +} + const SwitchHook: Hook = { mounted(this: object & HookInterface & SwitchHookState) { const el = this.el; const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); this.wasFocused = false; const zagSwitch = new Switch(el, { id: el.id, @@ -27,91 +40,85 @@ const SwitchHook: Hook = { name: getString(el, "name"), form: getString(el, "form"), value: getString(el, "value"), - dir: getString(el, "dir", ["ltr", "rtl"]), + dir: getDir(el), invalid: getBoolean(el, "invalid"), required: getBoolean(el, "required"), readOnly: getBoolean(el, "readOnly"), label: getString(el, "label"), onCheckedChange: (details: CheckedChangeDetails) => { - const eventName = getString(el, "onCheckedChange"); - if (eventName && canPushEvent(this.liveSocket)) { - pushEvent(eventName, { - checked: details.checked, - id: el.id, - }); - } - - const eventNameClient = getString(el, "onCheckedChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { - id: el.id, - checked: details.checked, - }, - }) - ); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: checkedChangePayload(el, details), + serverEventName: getString(el, "onCheckedChange"), + clientEventName: getString(el, "onCheckedChangeClient"), + }); }, }); zagSwitch.init(); this.zagSwitch = zagSwitch; - this.onSetChecked = (event: Event) => { - const { checked } = (event as CustomEvent<{ checked: boolean }>).detail; + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:switch:set-checked", (event) => { + const { checked } = event.detail; zagSwitch.api.setChecked(checked); - }; - el.addEventListener("phx:switch:set-checked", this.onSetChecked); - - this.handlers = []; - - this.handlers.push( - this.handleEvent("switch_set_checked", (payload: { id?: string; checked: boolean }) => { - const targetId = payload.id; - if (targetId && targetId !== el.id) return; - zagSwitch.api.setChecked(payload.checked); - }) - ); - - this.handlers.push( - this.handleEvent("switch_toggle_checked", (payload: { id?: string }) => { - const targetId = payload.id; - if (targetId && targetId !== el.id) return; - zagSwitch.api.toggleChecked(); - }) - ); - - this.handlers.push( - this.handleEvent("switch_checked", () => { - this.pushEvent("switch_checked_response", { - value: zagSwitch.api.checked, - }); - }) - ); + }); - this.handlers.push( - this.handleEvent("switch_focused", () => { - this.pushEvent("switch_focused_response", { - value: zagSwitch.api.focused, - }); - }) - ); + domRegistry.add("corex:switch:toggle-checked", () => { + zagSwitch.api.toggleChecked(); + }); - this.handlers.push( - this.handleEvent("switch_disabled", () => { - this.pushEvent("switch_disabled_response", { - value: zagSwitch.api.disabled, - }); - }) - ); + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add("switch_set_checked", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + const checked = readPayloadChecked(payload); + if (typeof checked === "boolean") zagSwitch.api.setChecked(checked); + }); + + registry.add("switch_toggle_checked", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + zagSwitch.api.toggleChecked(); + }); + + registry.add("switch_checked", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (!canPush()) return; + this.pushEvent("switch_checked_response", { + id: el.id, + value: zagSwitch.api.checked, + }); + }); + + registry.add("switch_focused", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (!canPush()) return; + this.pushEvent("switch_focused_response", { + id: el.id, + value: zagSwitch.api.focused, + }); + }); + + registry.add("switch_disabled", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (!canPush()) return; + this.pushEvent("switch_disabled_response", { + id: el.id, + value: zagSwitch.api.disabled, + }); + }); }, - beforeUpdate() { + beforeUpdate(this: object & HookInterface & SwitchHookState) { this.wasFocused = this.zagSwitch?.api.focused ?? false; }, + updated(this: object & HookInterface & SwitchHookState) { this.zagSwitch?.updateProps({ id: this.el.id, @@ -122,7 +129,7 @@ const SwitchHook: Hook = { name: getString(this.el, "name"), form: getString(this.el, "form"), value: getString(this.el, "value"), - dir: getString(this.el, "dir", ["ltr", "rtl"]), + dir: getDir(this.el), invalid: getBoolean(this.el, "invalid"), required: getBoolean(this.el, "required"), readOnly: getBoolean(this.el, "readOnly"), @@ -137,16 +144,8 @@ const SwitchHook: Hook = { }, destroyed(this: object & HookInterface & SwitchHookState) { - if (this.onSetChecked) { - this.el.removeEventListener("phx:switch:set-checked", this.onSetChecked); - } - - if (this.handlers) { - for (const handler of this.handlers) { - this.removeHandleEvent(handler); - } - } - + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.zagSwitch?.destroy(); }, }; diff --git a/assets/hooks/tabs.ts b/assets/hooks/tabs.ts index fa580da9..d1d31a60 100644 --- a/assets/hooks/tabs.ts +++ b/assets/hooks/tabs.ts @@ -1,109 +1,90 @@ 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 { Tabs } from "../components/tabs"; import type { ValueChangeDetails, FocusChangeDetails, Props } from "@zag-js/tabs"; import type { Direction, Orientation } from "@zag-js/types"; -import { getString, getBoolean } from "../lib/util"; +import { getString, getBoolean, canPushEvent } from "../lib/util"; +import { idMatches, readPayloadId, notifyChange } from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; type TabsHookState = { tabs?: Tabs; - handlers?: Array; - onSetValue?: (event: Event) => void; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; }; const TabsHook: Hook = { mounted(this: object & HookInterface & TabsHookState) { const el = this.el; const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const tabs = new Tabs(el, { id: el.id, ...(getBoolean(el, "controlled") ? { value: getString(el, "value") } : { defaultValue: getString(el, "defaultValue") }), - orientation: getString(el, "orientation", ["horizontal", "vertical"]), - dir: getString(el, "dir", ["ltr", "rtl"]), + orientation: getString(el, "orientation"), + dir: getString(el, "dir"), onValueChange: (details: ValueChangeDetails) => { - const eventName = getString(el, "onValueChange"); - if (eventName && this.liveSocket.main.isConnected()) { - pushEvent(eventName, { - id: el.id, - value: details.value ?? null, - }); - } - - const eventNameClient = getString(el, "onValueChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { - id: el.id, - value: details.value ?? null, - }, - }) - ); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, value: details.value ?? null } as Record, + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); }, onFocusChange: (details: FocusChangeDetails) => { - const eventName = getString(el, "onFocusChange"); - if (eventName && this.liveSocket.main.isConnected()) { - pushEvent(eventName, { - id: el.id, - value: details.focusedValue ?? null, - }); - } - - const eventNameClient = getString(el, "onFocusChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { - id: el.id, - value: details.focusedValue ?? null, - }, - }) - ); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: { id: el.id, value: details.focusedValue ?? null } as Record, + serverEventName: getString(el, "onFocusChange"), + clientEventName: getString(el, "onFocusChangeClient"), + }); }, }); tabs.init(); this.tabs = tabs; - this.onSetValue = (event: Event) => { - const { value } = (event as CustomEvent<{ value: string }>).detail; - tabs.api.setValue(value); - }; - el.addEventListener("phx:tabs:set-value", this.onSetValue); + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; - this.handlers = []; + domRegistry.add>("corex:tabs:set-value", (event) => { + tabs.api.setValue(event.detail.value); + }); - this.handlers.push( - this.handleEvent("tabs_set_value", (payload: { tabs_id?: string; value: string }) => { - const targetId = payload.tabs_id; - if (targetId && targetId !== el.id) return; - tabs.api.setValue(payload.value); - }) - ); + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; - this.handlers.push( - this.handleEvent("tabs_value", () => { - this.pushEvent("tabs_value_response", { - value: tabs.api.value, - }); - }) - ); + registry.add("tabs_set_value", (payload: { tabs_id?: string; value: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + tabs.api.setValue(payload.value); + }); - this.handlers.push( - this.handleEvent("tabs_focused_value", () => { - this.pushEvent("tabs_focused_value_response", { - value: tabs.api.focusedValue, - }); - }) - ); + registry.add("tabs_value", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (!canPush()) return; + this.pushEvent("tabs_value_response", { + id: el.id, + value: tabs.api.value, + }); + }); + + registry.add("tabs_focused_value", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (!canPush()) return; + this.pushEvent("tabs_focused_value_response", { + id: el.id, + value: tabs.api.focusedValue, + }); + }); }, updated(this: object & HookInterface & TabsHookState) { @@ -112,22 +93,14 @@ const TabsHook: Hook = { ...(getBoolean(this.el, "controlled") ? { value: getString(this.el, "value") } : { defaultValue: getString(this.el, "defaultValue") }), - orientation: getString(this.el, "orientation", ["horizontal", "vertical"]), - dir: getString(this.el, "dir", ["ltr", "rtl"]), + orientation: getString(this.el, "orientation"), + dir: getString(this.el, "dir"), } as Props); }, destroyed(this: object & HookInterface & TabsHookState) { - if (this.onSetValue) { - this.el.removeEventListener("phx:tabs:set-value", this.onSetValue); - } - - if (this.handlers) { - for (const handler of this.handlers) { - this.removeHandleEvent(handler); - } - } - + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.tabs?.destroy(); }, }; diff --git a/assets/hooks/timer.ts b/assets/hooks/timer.ts index 94af14eb..7519ae09 100644 --- a/assets/hooks/timer.ts +++ b/assets/hooks/timer.ts @@ -2,7 +2,9 @@ import type { Hook } from "phoenix_live_view"; import type { HookInterface, CallbackRef } from "phoenix_live_view/assets/js/types/view_hook"; import { Timer } from "../components/timer"; import type { Props, TickDetails } from "@zag-js/timer"; -import { getString, getBoolean, getNumber } from "../lib/util"; +import type { Orientation } from "@zag-js/types"; + +import { getString, getBoolean, getNumber, getDir, canPushEvent } from "../lib/util"; type TimerHookState = { timer?: Timer; @@ -12,28 +14,56 @@ type TimerHookState = { const TimerHook: Hook = { mounted(this: object & HookInterface & TimerHookState) { const el = this.el; + const pushEvent = this.pushEvent.bind(this); const zag = new Timer(el, { id: el.id, countdown: getBoolean(el, "countdown"), startMs: getNumber(el, "startMs"), targetMs: getNumber(el, "targetMs"), autoStart: getBoolean(el, "autoStart"), - interval: getNumber(el, "interval") ?? 1000, + interval: getNumber(el, "interval"), + dir: getDir(el), + orientation: getString(el, "orientation"), onTick: (details: TickDetails) => { const eventName = getString(el, "onTick"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { + if (eventName && canPushEvent(this.liveSocket)) { + pushEvent(eventName, { value: details.value, time: details.time, formattedTime: details.formattedTime, id: el.id, }); } + + const eventNameClient = getString(el, "onTickClient"); + if (eventNameClient) { + el.dispatchEvent( + new CustomEvent(eventNameClient, { + bubbles: true, + detail: { + id: el.id, + value: details.value, + time: details.time, + formattedTime: details.formattedTime, + }, + }) + ); + } }, onComplete: () => { const eventName = getString(el, "onComplete"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - this.pushEvent(eventName, { id: el.id }); + if (eventName && canPushEvent(this.liveSocket)) { + pushEvent(eventName, { id: el.id }); + } + + const eventNameClient = getString(el, "onCompleteClient"); + if (eventNameClient) { + el.dispatchEvent( + new CustomEvent(eventNameClient, { + bubbles: true, + detail: { id: el.id }, + }) + ); } }, } as Props); @@ -49,7 +79,9 @@ const TimerHook: Hook = { startMs: getNumber(this.el, "startMs"), targetMs: getNumber(this.el, "targetMs"), autoStart: getBoolean(this.el, "autoStart"), - interval: getNumber(this.el, "interval") ?? 1000, + interval: getNumber(this.el, "interval"), + dir: getDir(this.el), + orientation: getString(this.el, "orientation"), } as Partial); }, diff --git a/assets/hooks/toast.ts b/assets/hooks/toast.ts index 7bddccd9..4d19ed7f 100644 --- a/assets/hooks/toast.ts +++ b/assets/hooks/toast.ts @@ -13,8 +13,12 @@ type ToastPayload = { id?: string; duration?: number | string; groupId?: string; + loading?: boolean; }; +const loadingMeta = (loading: unknown) => + loading === true || loading === "true" ? { meta: { loading: true as const } } : {}; + type ToastHookState = { groupId: string; handlers?: Array; @@ -68,6 +72,8 @@ const ToastHook: Hook = { pauseOnPageIdle: getBoolean(el, "pauseOnPageIdle"), }); + el.setAttribute("data-ready", ""); + const store = getToastStore(this.groupId); const flashInfo = el.getAttribute("data-flash-info"); const flashInfoTitle = el.getAttribute("data-flash-info-title"); @@ -118,6 +124,7 @@ const ToastHook: Hook = { type: payload.type || "info", id: payload.id || generateId(undefined, "toast"), duration: parseDuration(payload.duration), + ...loadingMeta(payload.loading), }); } catch (error) { console.error("Failed to create toast:", error); @@ -167,6 +174,7 @@ const ToastHook: Hook = { type: detail.type || "info", id: detail.id || generateId(undefined, "toast"), duration: parseDuration(detail.duration), + ...loadingMeta(detail.loading), }); } catch (error) { console.error("Failed to create toast:", error); diff --git a/assets/hooks/toggle-group.ts b/assets/hooks/toggle-group.ts index f3966dd8..01b929f4 100644 --- a/assets/hooks/toggle-group.ts +++ b/assets/hooks/toggle-group.ts @@ -1,55 +1,61 @@ 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 { ToggleGroup } from "../components/toggle-group"; import type { ValueChangeDetails, Props } from "@zag-js/toggle-group"; -import type { Direction, Orientation } from "@zag-js/types"; +import type { Orientation } from "@zag-js/types"; -import { getString, getBoolean, getStringList } from "../lib/util"; +import { getString, getBoolean, getStringList, getDir, canPushEvent } from "../lib/util"; +import { idMatches, notifyChange, readPayloadId } from "../lib/respond-to"; +import { createHookHandleEventRegistry } from "../lib/hook-handlers"; +import { createDomEventRegistry } from "../lib/dom-events"; type ToggleGroupHookState = { toggleGroup?: ToggleGroup; - handlers?: Array; - onSetValue?: (event: Event) => void; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; }; +function valueChangePayload(el: HTMLElement, details: ValueChangeDetails): Record { + return { + id: el.id, + value: details.value, + }; +} + +function readPayloadValue(payload: unknown): string[] | undefined { + if (!payload || typeof payload !== "object") return undefined; + const o = payload as Record; + const v = o.value ?? o["value"]; + if (Array.isArray(v) && v.every((x) => typeof x === "string")) return v as string[]; + return undefined; +} + const ToggleGroupHook: Hook = { mounted(this: object & HookInterface & ToggleGroupHookState) { const el = this.el; const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); const props: Props = { id: el.id, ...(getBoolean(el, "controlled") ? { value: getStringList(el, "value") } : { defaultValue: getStringList(el, "defaultValue") }), - defaultValue: getStringList(el, "defaultValue"), deselectable: getBoolean(el, "deselectable"), loopFocus: getBoolean(el, "loopFocus"), rovingFocus: getBoolean(el, "rovingFocus"), disabled: getBoolean(el, "disabled"), multiple: getBoolean(el, "multiple"), - orientation: getString(el, "orientation", ["horizontal", "vertical"]), - dir: getString(el, "dir", ["ltr", "rtl"]), + orientation: getString(el, "orientation"), + dir: getDir(el), onValueChange: (details: ValueChangeDetails) => { - const eventName = getString(el, "onValueChange"); - if (eventName && !this.liveSocket.main.isDead && this.liveSocket.main.isConnected()) { - pushEvent(eventName, { - value: details.value, - id: el.id, - }); - } - - const eventNameClient = getString(el, "onValueChangeClient"); - if (eventNameClient) { - el.dispatchEvent( - new CustomEvent(eventNameClient, { - bubbles: true, - detail: { - value: details.value, - id: el.id, - }, - }) - ); - } + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: valueChangePayload(el, details), + serverEventName: getString(el, "onValueChange"), + clientEventName: getString(el, "onValueChangeClient"), + }); }, }; @@ -57,29 +63,31 @@ const ToggleGroupHook: Hook = { toggleGroup.init(); this.toggleGroup = toggleGroup; - this.onSetValue = (event: Event) => { - const { value } = (event as CustomEvent<{ value: string[] }>).detail; + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; + + domRegistry.add>("corex:toggle-group:set-value", (event) => { + const { value } = event.detail; toggleGroup.api.setValue(value); - }; - el.addEventListener("phx:toggle-group:set-value", this.onSetValue); + }); - this.handlers = []; + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; - this.handlers.push( - this.handleEvent("toggle-group_set_value", (payload: { id?: string; value: string[] }) => { - const targetId = payload.id; - if (targetId && targetId !== el.id) return; - toggleGroup.api.setValue(payload.value); - }) - ); + registry.add("toggle-group_set_value", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + const value = readPayloadValue(payload); + if (value) toggleGroup.api.setValue(value); + }); - this.handlers.push( - this.handleEvent("toggle-group:value", () => { - this.pushEvent("toggle-group:value_response", { - value: toggleGroup.api.value, - }); - }) - ); + registry.add("toggle-group:value", (payload: unknown) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + if (!canPush()) return; + this.pushEvent("toggle-group:value_response", { + id: el.id, + value: toggleGroup.api.value, + }); + }); }, updated(this: object & HookInterface & ToggleGroupHookState) { @@ -92,22 +100,14 @@ const ToggleGroupHook: Hook = { rovingFocus: getBoolean(this.el, "rovingFocus"), disabled: getBoolean(this.el, "disabled"), multiple: getBoolean(this.el, "multiple"), - orientation: getString(this.el, "orientation", ["horizontal", "vertical"]), - dir: getString(this.el, "dir", ["ltr", "rtl"]), + orientation: getString(this.el, "orientation"), + dir: getDir(this.el), }); }, destroyed(this: object & HookInterface & ToggleGroupHookState) { - if (this.onSetValue) { - this.el.removeEventListener("phx:toggle-group:set-value", this.onSetValue); - } - - if (this.handlers) { - for (const handler of this.handlers) { - this.removeHandleEvent(handler); - } - } - + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.toggleGroup?.destroy(); }, }; diff --git a/assets/hooks/tooltip.ts b/assets/hooks/tooltip.ts new file mode 100644 index 00000000..e84f1fd2 --- /dev/null +++ b/assets/hooks/tooltip.ts @@ -0,0 +1,126 @@ +import type { Hook } from "phoenix_live_view"; +import type { HookInterface, CallbackRef } from "phoenix_live_view/assets/js/types/view_hook"; +import { Tooltip } from "../components/tooltip"; +import type { OpenChangeDetails } from "@zag-js/tooltip"; +import type { Placement } from "@zag-js/popper"; + +import { getString, getBoolean, getNumber, getDir, canPushEvent } from "../lib/util"; +import { idMatches, readPayloadId } from "../lib/respond-to"; + +type TooltipHookState = { + tooltip?: Tooltip; + handlers?: Array; + onSetOpen?: (event: Event) => void; +}; + +function getCloseDelay(el: HTMLElement): number | undefined { + const interactive = getBoolean(el, "interactive"); + const raw = getNumber(el, "closeDelay"); + if (interactive && (raw === undefined || raw === 0)) return 400; + return raw; +} + +const TooltipHook: Hook = { + mounted(this: object & HookInterface & TooltipHookState) { + const el = this.el; + const pushEvent = this.pushEvent.bind(this); + + const placement = getString(el, "placement"); + const positioning = placement ? { placement } : undefined; + + const tooltip = new Tooltip(el, { + id: el.id, + ...(getBoolean(el, "controlled") + ? { open: getBoolean(el, "open") } + : { defaultOpen: getBoolean(el, "defaultOpen") }), + disabled: getBoolean(el, "disabled"), + dir: getDir(el), + openDelay: getNumber(el, "openDelay"), + closeDelay: getCloseDelay(el), + positioning, + closeOnEscape: getBoolean(el, "closeOnEscape"), + closeOnClick: getBoolean(el, "closeOnClick"), + closeOnPointerDown: getBoolean(el, "closeOnPointerDown"), + closeOnScroll: getBoolean(el, "closeOnScroll"), + interactive: getBoolean(el, "interactive"), + onOpenChange: (details: OpenChangeDetails) => { + const eventName = getString(el, "onOpenChange"); + if (eventName && canPushEvent(this.liveSocket)) { + pushEvent(eventName, { + id: el.id, + open: details.open, + }); + } + + const eventNameClient = getString(el, "onOpenChangeClient"); + if (eventNameClient) { + el.dispatchEvent( + new CustomEvent(eventNameClient, { + bubbles: true, + detail: { + id: el.id, + open: details.open, + }, + }) + ); + } + }, + }); + + tooltip.init(); + this.tooltip = tooltip; + + this.onSetOpen = (event: Event) => { + const { open } = (event as CustomEvent<{ open: boolean }>).detail; + tooltip.api.setOpen(open); + }; + el.addEventListener("corex:tooltip:set-open", this.onSetOpen); + + this.handlers = []; + + this.handlers.push( + this.handleEvent("tooltip_set_open", (payload: { tooltip_id?: string; open: boolean }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + tooltip.api.setOpen(payload.open); + }) + ); + }, + + updated(this: object & HookInterface & TooltipHookState) { + const placement = getString(this.el, "placement"); + const positioning = placement ? { placement } : undefined; + + this.tooltip?.updateProps({ + id: this.el.id, + ...(getBoolean(this.el, "controlled") + ? { open: getBoolean(this.el, "open") } + : { defaultOpen: getBoolean(this.el, "defaultOpen") }), + disabled: getBoolean(this.el, "disabled"), + dir: getDir(this.el), + openDelay: getNumber(this.el, "openDelay"), + closeDelay: getCloseDelay(this.el), + positioning, + closeOnEscape: getBoolean(this.el, "closeOnEscape"), + closeOnClick: getBoolean(this.el, "closeOnClick"), + closeOnPointerDown: getBoolean(this.el, "closeOnPointerDown"), + closeOnScroll: getBoolean(this.el, "closeOnScroll"), + interactive: getBoolean(this.el, "interactive"), + }); + }, + + destroyed(this: object & HookInterface & TooltipHookState) { + if (this.onSetOpen) { + this.el.removeEventListener("corex:tooltip:set-open", this.onSetOpen); + } + + if (this.handlers) { + for (const handler of this.handlers) { + this.removeHandleEvent(handler); + } + } + + this.tooltip?.destroy(); + }, +}; + +export { TooltipHook as Tooltip }; diff --git a/assets/hooks/tree-view.ts b/assets/hooks/tree-view.ts index b77e0114..a748047f 100644 --- a/assets/hooks/tree-view.ts +++ b/assets/hooks/tree-view.ts @@ -1,68 +1,166 @@ import type { Hook } from "phoenix_live_view"; -import type { HookInterface, CallbackRef } from "phoenix_live_view/assets/js/types/view_hook"; -import { TreeView } from "../components/tree-view"; -import { getString, getBoolean, getStringList, getDir } from "../lib/util"; +import type { HookInterface } from "phoenix_live_view/assets/js/types/view_hook"; +import type { ExpandedChangeDetails, SelectionChangeDetails } from "@zag-js/tree-view"; +import { TreeView, type TreeNode } from "../components/tree-view"; +import { getString, getBoolean, getStringList, getDir, canPushEvent } from "../lib/util"; +import { + readHeightAnimationOptions, + prepareInitialHeightState, + runOpenStateTransitionsHeight, +} from "../lib/animation"; +import { performRedirect, readDomItemRedirect } from "../lib/redirect"; +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 TreeViewExpandedChangedDetail, + type TreeViewSelectionChangedDetail, + diffStringValues, +} from "../lib/event-details"; type TreeViewHookState = { treeView?: TreeView; - handlers?: Array; - onSetExpandedValue?: (event: Event) => void; - onSetSelectedValue?: (event: Event) => void; + handleRegistry?: ReturnType; + domRegistry?: ReturnType; + lastDataTree?: string; + lastExpanded?: string[]; + lastSelected?: string[]; + lastExpandedAttr?: string; + lastSelectedAttr?: string; }; +function readExpandedAttr(el: HTMLElement): string { + return getBoolean(el, "controlled") + ? (el.getAttribute("data-expanded-value") ?? "") + : (el.getAttribute("data-default-expanded-value") ?? ""); +} + +function readSelectedAttr(el: HTMLElement): string { + return getBoolean(el, "controlled") + ? (el.getAttribute("data-selected-value") ?? "") + : (el.getAttribute("data-default-selected-value") ?? ""); +} + +function parseRootNode(el: HTMLElement): TreeNode { + const raw = el.dataset.tree; + if (raw == null || raw === "") { + throw new Error("TreeView: missing data-tree"); + } + return JSON.parse(raw) as TreeNode; +} + const TreeViewHook: Hook = { mounted(this: object & HookInterface & TreeViewHookState) { const el = this.el; + const self = this as object & HookInterface & TreeViewHookState; const pushEvent = this.pushEvent.bind(this); + const canPush = () => canPushEvent(this.liveSocket); + const rootNode = parseRootNode(el); + this.lastDataTree = el.dataset.tree; + + const controlled = getBoolean(el, "controlled"); + self.lastExpanded = controlled + ? (getStringList(el, "expandedValue") ?? []) + : (getStringList(el, "defaultExpandedValue") ?? []); + self.lastSelected = controlled + ? (getStringList(el, "selectedValue") ?? []) + : (getStringList(el, "defaultSelectedValue") ?? []); + self.lastExpandedAttr = readExpandedAttr(el); + self.lastSelectedAttr = readSelectedAttr(el); const treeView = new TreeView(el, { id: el.id, - ...(getBoolean(el, "controlled") + rootNode, + ...(controlled ? { - expandedValue: getStringList(el, "expandedValue"), - selectedValue: getStringList(el, "selectedValue"), + expandedValue: getStringList(el, "expandedValue") ?? [], + selectedValue: getStringList(el, "selectedValue") ?? [], } : { - defaultExpandedValue: getStringList(el, "defaultExpandedValue"), - defaultSelectedValue: getStringList(el, "defaultSelectedValue"), + defaultExpandedValue: getStringList(el, "defaultExpandedValue") ?? [], + defaultSelectedValue: getStringList(el, "defaultSelectedValue") ?? [], }), - selectionMode: - getString<"single" | "multiple">(el, "selectionMode", ["single", "multiple"]) ?? "single", + selectionMode: getString<"single" | "multiple">(el, "selectionMode") ?? "single", + typeahead: el.dataset.typeahead !== "false", dir: getDir(el), - onSelectionChange: (details) => { - const redirect = getBoolean(el, "redirect"); + onSelectionChange: (details: SelectionChangeDetails) => { + const redirectOn = getBoolean(el, "redirect"); const value = details.selectedValue?.length ? details.selectedValue[0] : undefined; - const itemEl = [ - ...el.querySelectorAll( - '[data-scope="tree-view"][data-part="item"], [data-scope="tree-view"][data-part="branch"]' - ), - ].find((node) => node.getAttribute("data-value") === value); - const isItem = itemEl?.getAttribute("data-part") === "item"; - const itemRedirect = itemEl?.getAttribute("data-redirect"); - const itemNewTab = itemEl?.hasAttribute("data-new-tab"); - const doRedirect = - redirect && value && isItem && this.liveSocket.main.isDead && itemRedirect !== "false"; - if (doRedirect) { - if (itemNewTab) { - window.open(value, "_blank", "noopener,noreferrer"); - } else { - window.location.href = value; - } - } - const eventName = getString(el, "onSelectionChange"); - if (eventName && this.liveSocket.main.isConnected()) { - pushEvent(eventName, { - id: el.id, - value: { ...details, isItem: isItem ?? false }, - }); + const itemEl = value + ? el.querySelector( + `[data-scope="tree-view"][data-part="item"][data-value="${CSS.escape(value)}"]` + ) + : null; + const isItem = !!itemEl; + + if (redirectOn && isItem) { + performRedirect(readDomItemRedirect(itemEl, value), { liveSocket: this.liveSocket }); } + + const next = details.selectedValue ?? []; + const previousSelectedValue = self.lastSelected ?? []; + const { added, removed } = diffStringValues(next, previousSelectedValue); + self.lastSelected = next; + + const payload: TreeViewSelectionChangedDetail = { + id: el.id, + selectedValue: next, + previousSelectedValue, + added, + removed, + focusedValue: details.focusedValue, + isItem, + }; + + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: payload as unknown as Record, + serverEventName: getString(el, "onSelectionChange"), + clientEventName: getString(el, "onSelectionChangeClient"), + }); }, - onExpandedChange: (details) => { - const eventName = getString(el, "onExpandedChange"); - if (eventName && this.liveSocket.main.isConnected()) { - pushEvent(eventName, { - id: el.id, - value: details, + onExpandedChange: (details: ExpandedChangeDetails) => { + const next = details.expandedValue ?? []; + const previousExpandedValue = self.lastExpanded ?? []; + const { added, removed } = diffStringValues(next, previousExpandedValue); + self.lastExpanded = next; + + const payload: TreeViewExpandedChangedDetail = { + id: el.id, + expandedValue: next, + previousExpandedValue, + added, + removed, + focusedValue: details.focusedValue, + }; + + notifyChange({ + el, + canPushServer: canPush(), + pushEvent, + payload: payload as unknown as Record, + serverEventName: getString(el, "onExpandedChange"), + clientEventName: getString(el, "onExpandedChangeClient"), + }); + + if (el.dataset.animation === "js") { + runOpenStateTransitionsHeight({ + rootEl: el, + selector: '[data-scope="tree-view"][data-part="branch-content"]', + opts: readHeightAnimationOptions(el), + isOpen: (contentEl) => { + const value = contentEl.dataset.value; + return !!value && next.includes(value); + }, }); } }, @@ -70,79 +168,148 @@ const TreeViewHook: Hook = { treeView.init(); this.treeView = treeView; - this.onSetExpandedValue = (event: Event) => { - const { value } = (event as CustomEvent<{ value: string[] }>).detail; - treeView.api.setExpandedValue(value); + if (el.dataset.animation === "js") { + const opts = readHeightAnimationOptions(el); + prepareInitialHeightState(el, '[data-scope="tree-view"][data-part="branch-content"]', opts); + } + + const emitSelectedValue = (respondTo: RespondTo) => { + const value = treeView.api.selectedValue; + emitResponse({ + respondTo, + canPushServer: canPush(), + pushEvent, + serverEventName: "tree_view_value_response", + serverPayload: { id: el.id, value } as Record, + el, + domEventName: "tree-view-value", + domDetail: { id: el.id, value } as Record, + }); }; - el.addEventListener("phx:tree-view:set-expanded-value", this.onSetExpandedValue); - this.onSetSelectedValue = (event: Event) => { - const { value } = (event as CustomEvent<{ value: string[] }>).detail; - treeView.api.setSelectedValue(value); + const emitExpandedValue = (respondTo: RespondTo) => { + const value = treeView.api.expandedValue; + emitResponse({ + respondTo, + canPushServer: canPush(), + pushEvent, + serverEventName: "tree_view_expanded_value_response", + serverPayload: { id: el.id, value } as Record, + el, + domEventName: "tree-view-expanded-value", + domDetail: { id: el.id, value } as Record, + }); }; - el.addEventListener("phx:tree-view:set-selected-value", this.onSetSelectedValue); - this.handlers = []; + const domRegistry = createDomEventRegistry(el); + this.domRegistry = domRegistry; - this.handlers.push( - this.handleEvent( - "tree_view_set_expanded_value", - (payload: { tree_view_id?: string; value: string[] }) => { - const targetId = payload.tree_view_id; - if (targetId && el.id !== targetId && el.id !== `tree-view:${targetId}`) return; - treeView.api.setExpandedValue(payload.value); - } - ) + domRegistry.add>( + "corex:tree-view:set-expanded-value", + (event) => { + treeView.api.setExpandedValue(event.detail.value); + } ); - this.handlers.push( - this.handleEvent( - "tree_view_set_selected_value", - (payload: { tree_view_id?: string; value: string[] }) => { - const targetId = payload.tree_view_id; - if (targetId && el.id !== targetId && el.id !== `tree-view:${targetId}`) return; - treeView.api.setSelectedValue(payload.value); - } - ) + domRegistry.add>( + "corex:tree-view:set-selected-value", + (event) => { + treeView.api.setSelectedValue(event.detail.value); + } ); - this.handlers.push( - this.handleEvent("tree_view_expanded_value", () => { - pushEvent("tree_view_expanded_value_response", { - value: treeView.api.expandedValue, - }); - }) + domRegistry.add("corex:tree-view:value", (event) => { + emitSelectedValue(parseRespondTo(event.detail)); + }); + + domRegistry.add("corex:tree-view:expanded-value", (event) => { + emitExpandedValue(parseRespondTo(event.detail)); + }); + + const registry = createHookHandleEventRegistry(this); + this.handleRegistry = registry; + + registry.add( + "tree_view_set_expanded_value", + (payload: { tree_view_id?: string; value: string[] }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + treeView.api.setExpandedValue(payload.value); + } ); - this.handlers.push( - this.handleEvent("tree_view_selected_value", () => { - pushEvent("tree_view_selected_value_response", { - value: treeView.api.selectedValue, - }); - }) + registry.add( + "tree_view_set_selected_value", + (payload: { tree_view_id?: string; value: string[] }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + treeView.api.setSelectedValue(payload.value); + } ); - }, - updated(this: object & HookInterface & TreeViewHookState) { - if (!getBoolean(this.el, "controlled")) return; - this.treeView?.updateProps({ - expandedValue: getStringList(this.el, "expandedValue"), - selectedValue: getStringList(this.el, "selectedValue"), + registry.add("tree_view_value", (payload: { id?: string; respond_to?: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + emitSelectedValue(parseRespondTo(payload)); + }); + + registry.add("tree_view_expanded_value", (payload: { id?: string; respond_to?: string }) => { + if (!idMatches(el.id, readPayloadId(payload))) return; + emitExpandedValue(parseRespondTo(payload)); }); }, - destroyed(this: object & HookInterface & TreeViewHookState) { - if (this.onSetExpandedValue) { - this.el.removeEventListener("phx:tree-view:set-expanded-value", this.onSetExpandedValue); - } - if (this.onSetSelectedValue) { - this.el.removeEventListener("phx:tree-view:set-selected-value", this.onSetSelectedValue); + updated(this: object & HookInterface & TreeViewHookState) { + const el = this.el; + const tv = this.treeView; + if (!tv) return; + + const rawTree = el.dataset.tree; + if (rawTree != null && rawTree !== this.lastDataTree) { + this.lastDataTree = rawTree; + tv.replaceRootNode(parseRootNode(el)); } - if (this.handlers) { - for (const handler of this.handlers) { - this.removeHandleEvent(handler); - } + + const controlled = getBoolean(el, "controlled"); + const selected = controlled + ? (getStringList(el, "selectedValue") ?? []) + : (getStringList(el, "defaultSelectedValue") ?? []); + const expanded = controlled + ? (getStringList(el, "expandedValue") ?? []) + : (getStringList(el, "defaultExpandedValue") ?? []); + + const selectionMode = getString<"single" | "multiple">(el, "selectionMode") ?? "single"; + const typeahead = el.dataset.typeahead !== "false"; + const dir = getDir(el); + + const expandedAttr = readExpandedAttr(el); + const selectedAttr = readSelectedAttr(el); + const expandedAttrChanged = expandedAttr !== this.lastExpandedAttr; + const selectedAttrChanged = selectedAttr !== this.lastSelectedAttr; + this.lastExpandedAttr = expandedAttr; + this.lastSelectedAttr = selectedAttr; + + if (controlled) { + if (expandedAttrChanged) this.lastExpanded = expanded; + if (selectedAttrChanged) this.lastSelected = selected; + tv.updateProps({ + expandedValue: expanded, + selectedValue: selected, + selectionMode, + typeahead, + dir, + }); + } else { + tv.updateProps({ + selectionMode, + typeahead, + dir, + }); + if (expandedAttrChanged) tv.api.setExpandedValue(expanded); + if (selectedAttrChanged) tv.api.setSelectedValue(selected); } + }, + + destroyed(this: object & HookInterface & TreeViewHookState) { + this.domRegistry?.teardown(); + this.handleRegistry?.teardown(); this.treeView?.destroy(); }, }; diff --git a/assets/lib/animation.ts b/assets/lib/animation.ts new file mode 100644 index 00000000..13055ed8 --- /dev/null +++ b/assets/lib/animation.ts @@ -0,0 +1,322 @@ +import { getBooleanValue } from "./util"; + +export type CorexHeightAnimationOptions = { + duration: number; + easing: string; + opacityStart: number; + opacityEnd: number; + blockInteraction: boolean; +}; + +export type CorexScaleAnimationOptions = { + duration: number; + easing: string; + opacityStart: number; + opacityEnd: number; + scaleStart: number; + scaleEnd: number; + blockInteraction: boolean; +}; + +function readRequiredAttrString(el: HTMLElement, dataAttr: string, label: string): string { + const raw = el.getAttribute(dataAttr); + if (raw === null) { + throw new Error(`[corex] missing ${label} on #${el.id}`); + } + return raw; +} + +function readRequiredAttrNumber(el: HTMLElement, dataAttr: string, label: string): number { + const raw = readRequiredAttrString(el, dataAttr, label); + const n = parseFloat(raw); + if (Number.isNaN(n)) { + throw new Error(`[corex] invalid ${label} on #${el.id}`); + } + return n; +} + +export function readHeightAnimationOptions(el: HTMLElement): CorexHeightAnimationOptions { + return { + duration: readRequiredAttrNumber(el, "data-anim-height-duration", "data-anim-height-duration"), + easing: readRequiredAttrString(el, "data-anim-height-easing", "data-anim-height-easing"), + opacityStart: readRequiredAttrNumber( + el, + "data-anim-height-opacity-start", + "data-anim-height-opacity-start" + ), + opacityEnd: readRequiredAttrNumber( + el, + "data-anim-height-opacity-end", + "data-anim-height-opacity-end" + ), + blockInteraction: getBooleanValue(el, "animHeightBlockInteraction") !== false, + }; +} + +export function readScaleAnimationOptions(el: HTMLElement): CorexScaleAnimationOptions { + return { + duration: readRequiredAttrNumber(el, "data-anim-scale-duration", "data-anim-scale-duration"), + easing: readRequiredAttrString(el, "data-anim-scale-easing", "data-anim-scale-easing"), + opacityStart: readRequiredAttrNumber( + el, + "data-anim-scale-opacity-start", + "data-anim-scale-opacity-start" + ), + opacityEnd: readRequiredAttrNumber( + el, + "data-anim-scale-opacity-end", + "data-anim-scale-opacity-end" + ), + scaleStart: readRequiredAttrNumber( + el, + "data-anim-transform-scale-start", + "data-anim-transform-scale-start" + ), + scaleEnd: readRequiredAttrNumber( + el, + "data-anim-transform-scale-end", + "data-anim-transform-scale-end" + ), + blockInteraction: getBooleanValue(el, "animScaleBlockInteraction") !== false, + }; +} + +const rootPointerBlockCount = new WeakMap(); + +function beginRootPointerBlock(root: HTMLElement): void { + const c = (rootPointerBlockCount.get(root) ?? 0) + 1; + rootPointerBlockCount.set(root, c); + if (c === 1) { + root.style.pointerEvents = "none"; + } +} + +function endRootPointerBlock(root: HTMLElement): void { + const c = (rootPointerBlockCount.get(root) ?? 0) - 1; + if (c <= 0) { + rootPointerBlockCount.delete(root); + root.style.removeProperty("pointer-events"); + } else { + rootPointerBlockCount.set(root, c); + } +} + +export function pointerBlockDuringMs( + root: HTMLElement, + durationMs: number, + enabled: boolean +): void { + if (!enabled || durationMs <= 0) return; + beginRootPointerBlock(root); + window.setTimeout(() => { + endRootPointerBlock(root); + }, durationMs); +} + +export type ScaleClosedStyleFeatures = Partial<{ scale: boolean }>; + +function applyScaleClosedStyles( + el: HTMLElement, + opts: CorexScaleAnimationOptions, + features?: ScaleClosedStyleFeatures +): void { + const isBackdrop = el.dataset.part === "backdrop"; + const sc = + features?.scale !== false && + !isBackdrop && + (opts.scaleStart !== opts.scaleEnd || opts.scaleStart !== 1 || opts.scaleEnd !== 1); + el.style.opacity = String(opts.opacityStart); + if (sc) { + el.style.transform = `scale(${opts.scaleStart})`; + } else { + el.style.removeProperty("transform"); + } +} + +function applyHeightClosedStyles(el: HTMLElement, opts: CorexHeightAnimationOptions): void { + el.style.opacity = String(opts.opacityStart); + el.style.height = "0px"; + el.style.overflow = "hidden"; + el.style.removeProperty("transform"); +} + +export function stripHiddenFromProps(props: Record): Record { + const next = { ...props }; + delete next.hidden; + return next; +} + +export function clearOpenStyles(el: HTMLElement): void { + el.style.opacity = ""; + el.style.height = ""; + el.style.overflow = ""; + el.style.removeProperty("transform"); +} + +export function prepareInitialHeightState( + rootEl: HTMLElement, + selector: string, + opts: CorexHeightAnimationOptions +): void { + rootEl.querySelectorAll(selector).forEach((el) => { + if (el.dataset.state === "open") { + clearOpenStyles(el); + } else { + applyHeightClosedStyles(el, opts); + } + }); +} + +export function prepareInitialScaleState( + rootEl: HTMLElement, + selector: string, + opts: CorexScaleAnimationOptions, + closedStyleFor?: (el: HTMLElement) => ScaleClosedStyleFeatures | undefined +): void { + rootEl.querySelectorAll(selector).forEach((el) => { + if (el.dataset.state === "open") { + clearOpenStyles(el); + } else { + applyScaleClosedStyles(el, opts, closedStyleFor?.(el)); + } + }); +} + +export function runOpenStateTransitionsHeight(args: { + rootEl: HTMLElement; + selector: string; + opts: CorexHeightAnimationOptions; + isOpen: (el: HTMLElement) => boolean; + wasOpen?: (el: HTMLElement) => boolean; +}): void { + const { rootEl, selector, opts, isOpen, wasOpen } = args; + const blockRoot = opts.blockInteraction ? rootEl : undefined; + rootEl.querySelectorAll(selector).forEach((el) => { + const previouslyOpen = wasOpen ? wasOpen(el) : el.dataset.state === "open"; + const nowOpen = isOpen(el); + if (previouslyOpen === nowOpen) return; + runHeightPanelAnimation(el, nowOpen, opts, blockRoot); + }); +} + +export function runHeightPanelAnimation( + targetEl: HTMLElement, + isOpening: boolean, + opts: CorexHeightAnimationOptions, + blockRoot?: HTMLElement +): Animation { + targetEl.getAnimations().forEach((a) => a.cancel()); + + const fromOp = isOpening ? opts.opacityStart : opts.opacityEnd; + const toOp = isOpening ? opts.opacityEnd : opts.opacityStart; + + targetEl.style.overflow = "hidden"; + targetEl.style.height = "auto"; + const fullHeight = `${targetEl.scrollHeight}px`; + targetEl.style.height = isOpening ? "0px" : fullHeight; + + const fromFrame = { + opacity: fromOp, + height: isOpening ? "0px" : fullHeight, + }; + const toFrame = { + opacity: toOp, + height: isOpening ? fullHeight : "0px", + }; + + if (blockRoot && opts.blockInteraction) { + beginRootPointerBlock(blockRoot); + } + + const anim = targetEl.animate([fromFrame, toFrame], { + duration: opts.duration * 1000, + easing: opts.easing, + fill: "forwards", + }); + let finished = false; + const finish = (): void => { + if (finished) return; + finished = true; + anim.cancel(); + if (blockRoot && opts.blockInteraction) { + endRootPointerBlock(blockRoot); + } + if (isOpening) { + targetEl.style.height = "auto"; + targetEl.style.opacity = ""; + targetEl.style.overflow = ""; + } else { + targetEl.style.height = "0px"; + targetEl.style.opacity = String(opts.opacityStart); + targetEl.style.overflow = "hidden"; + } + }; + anim.onfinish = () => { + finish(); + }; + anim.oncancel = () => { + finish(); + }; + return anim; +} + +export function runScaleAnimation( + targetEl: HTMLElement, + isOpening: boolean, + opts: CorexScaleAnimationOptions +): Animation { + targetEl.getAnimations().forEach((a) => a.cancel()); + + const isBackdrop = targetEl.dataset.part === "backdrop"; + const useScale = + !isBackdrop && + (opts.scaleStart !== opts.scaleEnd || opts.scaleStart !== 1 || opts.scaleEnd !== 1); + + const fromOp = isOpening ? opts.opacityStart : opts.opacityEnd; + const toOp = isOpening ? opts.opacityEnd : opts.opacityStart; + const fromS = isOpening ? opts.scaleStart : opts.scaleEnd; + const toS = isOpening ? opts.scaleEnd : opts.scaleStart; + + const fromFrame: Record = { opacity: fromOp }; + const toFrame: Record = { opacity: toOp }; + if (useScale) { + fromFrame.transform = `scale(${fromS})`; + toFrame.transform = `scale(${toS})`; + } + + const anim = targetEl.animate([fromFrame, toFrame], { + duration: opts.duration * 1000, + easing: opts.easing, + fill: "forwards", + }); + anim.onfinish = () => { + anim.cancel(); + if (isOpening) { + targetEl.style.opacity = ""; + if (useScale) { + targetEl.style.removeProperty("transform"); + } else { + targetEl.style.removeProperty("transform"); + } + } else { + targetEl.style.opacity = String(opts.opacityStart); + if (isBackdrop) { + targetEl.style.removeProperty("transform"); + } else if (useScale) { + targetEl.style.transform = `scale(${opts.scaleStart})`; + } else { + targetEl.style.removeProperty("transform"); + } + } + }; + return anim; +} + +export function animateHeightOpacityPanel( + contentEl: HTMLElement, + isOpening: boolean, + opts: CorexHeightAnimationOptions, + blockRoot?: HTMLElement +): Animation { + return runHeightPanelAnimation(contentEl, isOpening, opts, blockRoot); +} diff --git a/assets/lib/core.ts b/assets/lib/core.ts index 94f35ccb..931994af 100644 --- a/assets/lib/core.ts +++ b/assets/lib/core.ts @@ -1,4 +1,4 @@ -import { VanillaMachine, spreadProps } from "@zag-js/vanilla"; +import { VanillaMachine, spreadProps, normalizeProps } from "@zag-js/vanilla"; import type { Attrs } from "@zag-js/vanilla"; interface ComponentInterface { @@ -33,16 +33,20 @@ export abstract class Component implements ComponentInterface { abstract render(): void; init = () => { - this.render(); + try { + this.machine.start(); + this.render(); + } finally { + this.el.removeAttribute("data-loading"); + } this.machine.subscribe(() => { this.api = this.initApi(); this.render(); }); - this.machine.start(); - this.el.removeAttribute("data-js"); }; destroy = () => { + this.el.removeAttribute("data-loading"); this.machine.stop(); }; @@ -53,4 +57,11 @@ export abstract class Component implements ComponentInterface { updateProps = (props: Attrs) => { this.machine.updateProps(props); }; + + protected zagConnect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + connectFn: (service: any, np: typeof normalizeProps) => A + ): A { + return connectFn(this.machine.service, normalizeProps); + } } diff --git a/assets/lib/custom-animation.ts b/assets/lib/custom-animation.ts new file mode 100644 index 00000000..f966da56 --- /dev/null +++ b/assets/lib/custom-animation.ts @@ -0,0 +1,125 @@ +export type Animator = ( + el: HTMLElement, + keyframes: Record, + options: { duration: number; easing: string | number[] } +) => { finished: Promise }; + +export type AnimateHeightOptions = { + animator: Animator; + duration?: number; + easing?: string | number[]; + opacityStart?: number; + opacityEnd?: number; +}; + +const DEFAULT_DURATION = 0.3; +const DEFAULT_EASING: string = "ease-out"; +const DEFAULT_OPACITY_START = 0; +const DEFAULT_OPACITY_END = 1; + +function reducedMotion(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +export function applyClosedHeight(el: HTMLElement): void { + el.style.opacity = "0"; + el.style.height = "0px"; + el.style.overflow = "hidden"; +} + +export function applyOpenHeight(el: HTMLElement): void { + el.style.opacity = ""; + el.style.height = ""; + el.style.overflow = ""; +} + +export function findAccordionContent(rootEl: HTMLElement, value: string): HTMLElement | null { + return rootEl.querySelector( + `[data-scope="accordion"][data-part="item"][data-value="${CSS.escape(value)}"] [data-part="item-content"]` + ); +} + +export function findTreeBranch(rootEl: HTMLElement, value: string): HTMLElement | null { + return rootEl.querySelector( + `[data-scope="tree-view"][data-part="branch-content"][data-value="${CSS.escape(value)}"]` + ); +} + +export function initCustomCollections(): void { + document + .querySelectorAll('[data-animation="custom"][phx-hook="Accordion"]') + .forEach((host) => { + host + .querySelectorAll('[data-scope="accordion"][data-part="item-content"]') + .forEach((el) => { + if (el.dataset.state !== "open") applyClosedHeight(el); + }); + }); + document + .querySelectorAll('[data-animation="custom"][phx-hook="TreeView"]') + .forEach((host) => { + host + .querySelectorAll('[data-scope="tree-view"][data-part="branch-content"]') + .forEach((el) => { + if (el.dataset.state !== "open") applyClosedHeight(el); + }); + }); +} + +export function animateHeightOpen(el: HTMLElement, opts: AnimateHeightOptions): Promise { + if (reducedMotion()) { + applyOpenHeight(el); + return Promise.resolve(); + } + const duration = opts.duration ?? DEFAULT_DURATION; + const easing = opts.easing ?? DEFAULT_EASING; + const opacityStart = opts.opacityStart ?? DEFAULT_OPACITY_START; + const opacityEnd = opts.opacityEnd ?? DEFAULT_OPACITY_END; + + const toHeight = `${el.scrollHeight}px`; + el.style.height = "0px"; + el.style.overflow = "hidden"; + + return Promise.resolve( + opts + .animator( + el, + { height: ["0px", toHeight], opacity: [opacityStart, opacityEnd] }, + { duration, easing } + ) + .finished.then(() => { + applyOpenHeight(el); + }) + ).then(() => undefined); +} + +export function animateHeightClose(el: HTMLElement, opts: AnimateHeightOptions): Promise { + if (reducedMotion()) { + applyClosedHeight(el); + return Promise.resolve(); + } + const duration = opts.duration ?? DEFAULT_DURATION; + const easing = opts.easing ?? DEFAULT_EASING; + const opacityStart = opts.opacityStart ?? DEFAULT_OPACITY_START; + const opacityEnd = opts.opacityEnd ?? DEFAULT_OPACITY_END; + + const fromHeight = `${el.scrollHeight}px`; + el.style.height = fromHeight; + el.style.overflow = "hidden"; + + return Promise.resolve( + opts + .animator( + el, + { height: [fromHeight, "0px"], opacity: [opacityEnd, opacityStart] }, + { duration, easing } + ) + .finished.then(() => { + applyClosedHeight(el); + }) + ).then(() => undefined); +} diff --git a/assets/lib/dom-events.ts b/assets/lib/dom-events.ts new file mode 100644 index 00000000..68b319c9 --- /dev/null +++ b/assets/lib/dom-events.ts @@ -0,0 +1,32 @@ +type DomEventTarget = { + addEventListener: (name: string, listener: EventListener) => void; + removeEventListener: (name: string, listener: EventListener) => void; +}; + +export interface DomEventRegistry { + add: (eventName: string, listener: (event: E) => void) => void; + teardown: () => void; +} + +interface RegistryEntry { + eventName: string; + listener: EventListener; +} + +export function createDomEventRegistry(target: DomEventTarget): DomEventRegistry { + const entries: RegistryEntry[] = []; + + return { + add(eventName: string, listener: (event: E) => void) { + const wrapped = listener as EventListener; + target.addEventListener(eventName, wrapped); + entries.push({ eventName, listener: wrapped }); + }, + teardown() { + for (const { eventName, listener } of entries) { + target.removeEventListener(eventName, listener); + } + entries.length = 0; + }, + }; +} diff --git a/assets/lib/event-details.ts b/assets/lib/event-details.ts new file mode 100644 index 00000000..00303d1e --- /dev/null +++ b/assets/lib/event-details.ts @@ -0,0 +1,41 @@ +export type AccordionChangedDetail = { + id: string; + value: string[]; + previousValue: string[]; + added: string[]; + removed: string[]; +}; + +export type TreeViewExpandedChangedDetail = { + id: string; + expandedValue: string[]; + previousExpandedValue: string[]; + added: string[]; + removed: string[]; + focusedValue: string | null; +}; + +export type TreeViewSelectionChangedDetail = { + id: string; + selectedValue: string[]; + previousSelectedValue: string[]; + added: string[]; + removed: string[]; + focusedValue: string | null; + isItem: boolean; +}; + +export type DialogOpenChangedDetail = { + id: string; + open: boolean; + previousOpen: boolean; +}; + +export function diffStringValues( + next: readonly string[], + previous: readonly string[] +): { added: string[]; removed: string[] } { + const added = next.filter((v) => !previous.includes(v)); + const removed = previous.filter((v) => !next.includes(v)); + return { added, removed }; +} diff --git a/assets/lib/hook-handlers.ts b/assets/lib/hook-handlers.ts new file mode 100644 index 00000000..fd552911 --- /dev/null +++ b/assets/lib/hook-handlers.ts @@ -0,0 +1,24 @@ +import type { HookInterface, CallbackRef } from "phoenix_live_view/assets/js/types/view_hook"; + +export interface HookHandleEventRegistry { + add:

(eventName: string, fn: (payload: P) => void) => void; + teardown: () => void; +} + +export function createHookHandleEventRegistry( + hook: Pick, "handleEvent" | "removeHandleEvent"> +): HookHandleEventRegistry { + const refs: CallbackRef[] = []; + + return { + add

(eventName: string, fn: (payload: P) => void) { + refs.push(hook.handleEvent(eventName, fn as (payload: unknown) => void)); + }, + teardown() { + for (const ref of refs) { + hook.removeHandleEvent(ref); + } + refs.length = 0; + }, + }; +} diff --git a/assets/lib/list-collection.ts b/assets/lib/list-collection.ts new file mode 100644 index 00000000..9b64f030 --- /dev/null +++ b/assets/lib/list-collection.ts @@ -0,0 +1,52 @@ +export type IdValueLabelItem = { + id?: string; + value?: string; + label: string; + disabled?: boolean; + group?: string; +}; + +export function itemToIdOrValue(item: IdValueLabelItem): string { + return item.id ?? item.value ?? ""; +} + +export function zagIdValueLabelCollectionConfig( + items: T[], + hasGroups: boolean +) { + if (hasGroups) { + return { + items, + itemToValue: (item: T) => itemToIdOrValue(item), + itemToString: (item: T) => item.label, + isItemDisabled: (item: T) => !!item.disabled, + groupBy: (item: T) => item.group ?? "", + }; + } + return { + items, + itemToValue: (item: T) => itemToIdOrValue(item), + itemToString: (item: T) => item.label, + isItemDisabled: (item: T) => !!item.disabled, + }; +} + +export type ComboboxListItem = { id?: string; label: string; disabled?: boolean; group?: string }; + +export function zagComboboxCollectionConfig(items: ComboboxListItem[], hasGroups: boolean) { + if (hasGroups) { + return { + items, + itemToValue: (item: ComboboxListItem) => item.id ?? "", + itemToString: (item: ComboboxListItem) => item.label, + isItemDisabled: (item: ComboboxListItem) => !!item.disabled, + groupBy: (item: ComboboxListItem) => item.group ?? "", + }; + } + return { + items, + itemToValue: (item: ComboboxListItem) => item.id ?? "", + itemToString: (item: ComboboxListItem) => item.label, + isItemDisabled: (item: ComboboxListItem) => !!item.disabled, + }; +} diff --git a/assets/lib/positioning.ts b/assets/lib/positioning.ts new file mode 100644 index 00000000..9e837a2a --- /dev/null +++ b/assets/lib/positioning.ts @@ -0,0 +1,43 @@ +import type { PositioningOptions } from "@zag-js/popper"; +import { getString, getNumber, getBooleanValue } from "./util"; + +export function readFlipAttr(el: HTMLElement): PositioningOptions["flip"] | undefined { + const raw = el.dataset.positionFlip; + if (raw == null) return undefined; + if (raw === "true") return true; + if (raw === "false") return false; + const list = raw + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + return list.length > 0 ? (list as PositioningOptions["flip"]) : undefined; +} + +export function readPositioningOptions(el: HTMLElement): PositioningOptions | undefined { + const options: Record = {}; + const strategy = getString(el, "positionStrategy"); + if (strategy) options.strategy = strategy; + const placement = getString(el, "positionPlacement"); + if (placement) options.placement = placement; + const gutter = getNumber(el, "positionGutter"); + if (gutter !== undefined) options.gutter = gutter; + const shift = getNumber(el, "positionShift"); + if (shift !== undefined) options.shift = shift; + const overflowPadding = getNumber(el, "positionOverflowPadding"); + if (overflowPadding !== undefined) options.overflowPadding = overflowPadding; + const arrowPadding = getNumber(el, "positionArrowPadding"); + if (arrowPadding !== undefined) options.arrowPadding = arrowPadding; + const flip = readFlipAttr(el); + if (flip !== undefined) options.flip = flip; + const slide = getBooleanValue(el, "positionSlide"); + if (slide !== undefined) options.slide = slide; + const overlap = getBooleanValue(el, "positionOverlap"); + if (overlap !== undefined) options.overlap = overlap; + const sameWidth = getBooleanValue(el, "positionSameWidth"); + if (sameWidth !== undefined) options.sameWidth = sameWidth; + const fitViewport = getBooleanValue(el, "positionFitViewport"); + if (fitViewport !== undefined) options.fitViewport = fitViewport; + const hideWhenDetached = getBooleanValue(el, "positionHideWhenDetached"); + if (hideWhenDetached !== undefined) options.hideWhenDetached = hideWhenDetached; + return Object.keys(options).length > 0 ? (options as PositioningOptions) : undefined; +} diff --git a/assets/lib/read-props.ts b/assets/lib/read-props.ts new file mode 100644 index 00000000..cf51fb27 --- /dev/null +++ b/assets/lib/read-props.ts @@ -0,0 +1,34 @@ +import { getBoolean, getNumber, getString } from "./util"; + +const z = (s: string | undefined) => (s === undefined ? null : s); + +export function readStringControlledZagProps( + el: HTMLElement, + valueKey: string, + defaultKey: string +): { value: string | null } | { defaultValue: string | null } { + return getBoolean(el, "controlled") + ? { value: z(getString(el, valueKey)) } + : { defaultValue: z(getString(el, defaultKey)) }; +} + +export function readStringControlledZagUpdate( + el: HTMLElement, + valueKey: string, + defaultKey: string +): Record { + return getBoolean(el, "controlled") + ? { value: z(getString(el, valueKey)) } + : { defaultValue: z(getString(el, defaultKey)) }; +} + +type NumZag = + | { value: number | undefined; step: number | undefined; defaultValue?: never } + | { value?: never; defaultValue: number | undefined; step: number | undefined }; + +export function readNumberControlledZagProps(el: HTMLElement): NumZag { + const step = getNumber(el, "step"); + return getBoolean(el, "controlled") + ? { value: getNumber(el, "value"), step } + : { defaultValue: getNumber(el, "defaultValue"), step }; +} diff --git a/assets/lib/redirect.ts b/assets/lib/redirect.ts new file mode 100644 index 00000000..da89edfc --- /dev/null +++ b/assets/lib/redirect.ts @@ -0,0 +1,98 @@ +/** + * Shared redirect helper used by tree-view, menu, select, listbox, and combobox hooks. + * + * The caller picks the navigation kind per item via `mode`. JS never tries to + * detect whether a URL belongs to the same LiveView mount; the hook only + * executes what was declared on the item via DOM attributes. + */ + +export type RedirectMode = "href" | "patch" | "navigate"; + +const REDIRECT_MODES: readonly RedirectMode[] = ["href", "patch", "navigate"]; + +export interface RedirectInput { + destination: string; + newTab?: boolean; + mode?: RedirectMode; +} + +export interface RedirectContext { + liveSocket: { + main: { isDead: boolean; isConnected: () => boolean }; + js: () => { patch: (url: string) => void; navigate: (url: string) => void }; + }; +} + +/** + * Build a RedirectInput from an item element's data attributes. + * + * - Returns `null` if the element has `data-redirect="false"` (opt-out). + * - `destination = data-to || fallback || data-value`. + * - `mode` is read from `data-redirect` when its value is one of "href", + * "patch", "navigate". Anything else (including missing) leaves it + * unset so `performRedirect` falls back to its `"href"` default. + * - `newTab` mirrors the presence of `data-new-tab`. + */ +export function readDomItemRedirect( + itemEl: HTMLElement | null | undefined, + fallback?: string +): RedirectInput | null { + if (!itemEl) { + if (!fallback) return null; + return { destination: fallback }; + } + + const dataRedirect = itemEl.getAttribute("data-redirect"); + if (dataRedirect === "false") return null; + + const destination = + itemEl.getAttribute("data-to") || fallback || itemEl.getAttribute("data-value") || ""; + if (!destination) return null; + + const mode = REDIRECT_MODES.includes(dataRedirect as RedirectMode) + ? (dataRedirect as RedirectMode) + : undefined; + const newTab = itemEl.hasAttribute("data-new-tab"); + + return { destination, mode, newTab }; +} + +/** + * Execute a redirect described by `input`. + * + * Behavior: + * - No-op (returns false) when `input` is null or has empty destination. + * - `newTab === true` -> always `window.open(_, "_blank", noopener,noreferrer)`. + * - LV not connected -> `window.location.href = destination` regardless of mode. + * - LV connected: + * - `mode === "patch"` -> `liveSocket.js().patch(destination)` + * - `mode === "navigate"` -> `liveSocket.js().navigate(destination)` + * - `mode === "href"` (default) -> `window.location.href = destination` + * + * Returns true when a redirect was attempted, false otherwise. + */ +export function performRedirect(input: RedirectInput | null, ctx: RedirectContext): boolean { + if (!input || !input.destination) return false; + const { destination, newTab, mode } = input; + + if (newTab) { + window.open(destination, "_blank", "noopener,noreferrer"); + return true; + } + + const main = ctx.liveSocket.main; + const connected = !main.isDead && main.isConnected(); + + if (!connected || !mode || mode === "href") { + window.location.href = destination; + return true; + } + + const js = ctx.liveSocket.js(); + if (mode === "patch") { + js.patch(destination); + } else { + js.navigate(destination); + } + return true; +} diff --git a/assets/lib/respond-to.ts b/assets/lib/respond-to.ts new file mode 100644 index 00000000..0f94b3aa --- /dev/null +++ b/assets/lib/respond-to.ts @@ -0,0 +1,117 @@ +export type RespondTo = "server" | "client" | "both"; + +export function parseRespondTo(source: unknown): RespondTo { + if (source && typeof source === "object") { + const o = source as Record; + const raw = + o.respond_to ?? + o.respondTo ?? + (typeof o["respond_to"] === "string" ? o["respond_to"] : undefined) ?? + (typeof o["respondTo"] === "string" ? o["respondTo"] : undefined); + if (raw === "server" || raw === "client" || raw === "both") return raw; + } + return "server"; +} + +type EmitResponseArgs> = { + respondTo: RespondTo; + canPushServer: boolean; + pushEvent: (name: string, payload: TPayload) => void; + serverEventName: string; + serverPayload: TPayload; + el: HTMLElement; + domEventName: string; + domDetail: TPayload; +}; + +export function idMatches(elId: string, payloadId: string | undefined | null): boolean { + if (payloadId === undefined || payloadId === null || payloadId === "") return true; + return elId === payloadId; +} + +export function readPayloadChecked(payload: unknown): boolean | undefined { + if (!payload || typeof payload !== "object") return undefined; + const o = payload as Record; + const c = o.checked ?? o["checked"]; + if (c === true || c === "true" || c === 1) return true; + if (c === false || c === "false" || c === 0) return false; + return undefined; +} + +export function readPayloadId(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object") return; + const o = payload as Record; + let generic: string | undefined; + for (const k of Object.keys(o)) { + const v = o[k]; + if (typeof v !== "string" || v === "") continue; + if (k === "id" || k === "Id") { + generic = v; + } else if (k.includes("_id") || (k.length > 2 && k.endsWith("Id"))) { + return v; + } + } + return generic; +} + +export function readPayloadValue(payload: unknown): string { + if (!payload || typeof payload !== "object") return ""; + const o = payload as Record; + const v = o.value ?? o["value"]; + if (v === undefined || v === null) return ""; + return String(v); +} + +type NotifyChangeArgs> = { + el: HTMLElement; + canPushServer: boolean; + pushEvent: (name: string, payload: TPayload) => void; + payload: TPayload; + serverEventName?: string | null; + clientEventName?: string | null; +}; + +export function notifyChange>( + args: NotifyChangeArgs +): void { + const { el, canPushServer, pushEvent, payload, serverEventName, clientEventName } = args; + + if (serverEventName && canPushServer) { + pushEvent(serverEventName, { ...payload }); + } + if (clientEventName) { + el.dispatchEvent( + new CustomEvent(clientEventName, { + bubbles: true, + detail: payload, + }) + ); + } +} + +export function emitResponse>( + args: EmitResponseArgs +): void { + const { + respondTo, + canPushServer, + pushEvent, + serverEventName, + serverPayload, + el, + domEventName, + domDetail, + } = args; + + if (respondTo !== "client" && canPushServer) { + pushEvent(serverEventName, serverPayload); + } + if (respondTo !== "server") { + el.dispatchEvent( + new CustomEvent(domEventName, { + bubbles: true, + detail: domDetail, + }) + ); + } +} diff --git a/assets/lib/util.ts b/assets/lib/util.ts index cf94a2a8..2d01fa9b 100644 --- a/assets/lib/util.ts +++ b/assets/lib/util.ts @@ -85,6 +85,33 @@ export const getBoolean = (element: HTMLElement, attrName: string): boolean => { const dashName = attrName.replace(/([A-Z])/g, "-$1").toLowerCase(); return element.hasAttribute(`data-${dashName}`); }; + +export const getBooleanValue = (element: HTMLElement, attrName: string): boolean | undefined => { + const raw = element.dataset[attrName]; + return raw === "true" ? true : raw === "false" ? false : undefined; +}; + +export type CheckedState = boolean | "indeterminate"; + +export function getCheckedState( + element: HTMLElement, + key: "checked" | "defaultChecked" +): CheckedState { + const raw = element.dataset[key]; + if (raw === "indeterminate") return "indeterminate"; + return raw === "true"; +} + +export function templatesContentRoot( + el: Element, + dataTemplates: string +): DocumentFragment | HTMLElement | null { + const host = el.querySelector(`[data-templates="${dataTemplates}"]`); + if (!host) return null; + if (host instanceof HTMLTemplateElement) return host.content; + return host as HTMLElement; +} + /** * Generate a random ID if none is provided * @param element - Optional HTML element to get an existing id diff --git a/config/config.exs b/config/config.exs index a319901d..e314732b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -2,7 +2,8 @@ import Config config :logger, :console, colors: [enabled: false], - format: "\n$time $metadata[$level] $message\n" + format: "\n$time $metadata[$level] $message\n", + metadata: :all config :phoenix, json_library: Jason, @@ -12,7 +13,7 @@ config :phoenix, if Mix.env() == :dev do corex_externals = - ~w(accordion angle-slider avatar carousel checkbox clipboard collapsible combobox color-picker date-picker dialog editable floating-panel listbox marquee menu number-input password-input pin-input radio-group select signature-pad switch tabs timer toast toggle-group tree-view) + ~w(accordion angle-slider avatar carousel checkbox clipboard code collapsible combobox color-picker date-picker dialog editable floating-panel listbox marquee menu number-input password-input pin-input radio-group select signature-pad switch tabs timer toast toggle-group tooltip tree-view) |> Enum.map(fn name -> "--external:corex/#{name}" end) esbuild = fn args -> @@ -31,6 +32,7 @@ if Mix.env() == :dev do ./hooks/carousel.ts ./hooks/checkbox.ts ./hooks/clipboard.ts + ./hooks/code.ts ./hooks/collapsible.ts ./hooks/combobox.ts ./hooks/color-picker.ts @@ -51,13 +53,15 @@ if Mix.env() == :dev do ./hooks/tabs.ts ./hooks/timer.ts ./hooks/toast.ts + ./hooks/tooltip.ts ./hooks/toggle-group.ts ./hooks/tree-view.ts ) hooks_args = hooks_entries ++ - ~w(--bundle --splitting --format=esm --outdir=../priv/static --out-extension:.js=.mjs) + ~w(--bundle --splitting --format=esm --outdir=../priv/static --out-extension:.js=.mjs) ++ + ["--chunk-names=chunks/[name]-[hash]"] config :esbuild, version: "0.25.4", diff --git a/coveralls.json b/coveralls.json index f2698593..e8fe4017 100644 --- a/coveralls.json +++ b/coveralls.json @@ -1,12 +1,18 @@ { "skip_files": [ - "lib/components/.*/anatomy\\.ex", - "test/support/endpoint\\.ex", - "test/support/gettext\\.ex", - "lib/corex/flash\\.ex", - "lib/corex/positoning\\.ex", - "lib/mix/tasks/corex.gen.schema.ex", - "lib/mix/tasks/corex.gen.html.ex", - "lib/mix/tasks/corex.gen.live.ex" + "lib/mix/corex/gen/context\\.ex", + "lib/mix/corex/install/assets\\.ex", + "lib/mix/corex/install/config\\.ex", + "lib/mix/corex/install/i18n\\.ex", + "lib/mix/corex/install/layouts\\.ex", + "lib/mix/corex/install/pipeline\\.ex", + "lib/mix/corex/install/templates\\.ex", + "lib/mix/corex/install/web\\.ex", + "lib/mix/corex\\.ex", + "lib/mix/tasks/corex\\.heroicon\\.ex", + "lib/mix/tasks/corex\\.install\\.ex", + "lib/mix/tasks/corex\\.gen\\.html\\.ex", + "lib/mix/tasks/corex\\.gen\\.live\\.ex", + "lib/mix/tasks/corex\\.gen\\.schema\\.ex" ] -} \ No newline at end of file +} diff --git a/e2e/.gitignore b/e2e/.gitignore index f6fa1da8..e2b388b4 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -34,8 +34,10 @@ e2e-*.tar # In case you use Node.js/npm, you want to ignore these. npm-debug.log -/assets/node_modules/ +/node_modules/ .env /.expert/ + +/.claude/ diff --git a/e2e/.tool-versions b/e2e/.tool-versions index 3d664e3f..da886627 100644 --- a/e2e/.tool-versions +++ b/e2e/.tool-versions @@ -1,2 +1,2 @@ elixir 1.19.5-otp-28 -erlang 28.3.1 +erlang 28.3.1 \ No newline at end of file diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md deleted file mode 100644 index 91295c02..00000000 --- a/e2e/AGENTS.md +++ /dev/null @@ -1,450 +0,0 @@ -This is a web application written using the Phoenix web framework. - -## Project guidelines - -- Use `mix precommit` alias when you are done with all changes and fix any pending issues -- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps -- Use `<.action>` for buttons (actions and submit); use `<.navigate>` for links (navigation, href, patch). Do not use raw ` + + """ + end + + def schema_code do + ~S""" + defmodule E2e.Form.CookiePreferences do + use Ecto.Schema + import Ecto.Changeset + + @frequencies ~w(daily weekly monthly never) + + embedded_schema do + field :analytics, :boolean, default: false + field :marketing, :boolean, default: false + field :frequency, :string, default: "weekly" + end + + def changeset(schema, attrs \\ %{}) do + schema + |> cast(attrs, [:analytics, :marketing, :frequency]) + |> validate_required([:frequency]) + |> validate_inclusion(:frequency, @frequencies) + end + end + """ + end + + def handler_code do + ~S""" + def mount(_params, _session, socket) do + form = + %E2e.Form.CookiePreferences{} + |> E2e.Form.CookiePreferences.changeset(%{}) + |> to_form(as: :cookie_preferences, id: "cookie-preferences-form") + + {:ok, assign(socket, :cookie_form, form)} + end + + def handle_event("save_cookies", %{"cookie_preferences" => params}, socket) do + case E2e.Form.CookiePreferences.changeset(%E2e.Form.CookiePreferences{}, params) do + %Ecto.Changeset{valid?: true} = cs -> + data = Ecto.Changeset.apply_changes(cs) + msg = "analytics=#{data.analytics}, marketing=#{data.marketing}, frequency=#{data.frequency}" + + {:noreply, + socket + |> Corex.Toast.push_toast("layout-toast", "Preferences saved", msg, :success, 5000) + |> assign(:cookie_form, fresh_form())} + + %Ecto.Changeset{} = cs -> + {:noreply, assign(socket, :cookie_form, to_form(cs, action: :insert, as: :cookie_preferences))} + end + end + """ + end +end diff --git a/e2e/lib/e2e_web/demos/data_list_demo.ex b/e2e/lib/e2e_web/demos/data_list_demo.ex new file mode 100644 index 00000000..33d16fb6 --- /dev/null +++ b/e2e/lib/e2e_web/demos/data_list_demo.ex @@ -0,0 +1,97 @@ +defmodule E2eWeb.Demos.DataListDemo do + use E2eWeb, :html + + def anatomy_basic_code do + ~S""" + <.data_list class="data-list"> + <:item title="Full name">Alice + <:item title="Nationality">Polish–French + <:item title="Field">Physics & Chemistry + <:item title="Nobel Prizes">2 + + """ + end + + def anatomy_basic_example(assigns) do + ~H""" + <.data_list class="data-list"> + <:item title="Full name">Alice + <:item title="Nationality">Polish–French + <:item title="Field">Physics & Chemistry + <:item title="Nobel Prizes">2 + + """ + end + + def anatomy_rich_values_code do + ~S""" + <.data_list class="data-list"> + <:item title="Account"> + + <.heroicon name="hero-user-circle" class="icon" /> alice@example.com + + + <:item title="Plan">Pro + <:item title="Status">Active + <:item title="Actions"> + + <.action class="button button--sm">Edit + <.action class="button button--sm button--red">Suspend + + + + """ + end + + def anatomy_rich_values_example(assigns) do + ~H""" + <.data_list class="data-list"> + <:item title="Account"> + + <.heroicon name="hero-user-circle" class="icon" /> alice@example.com + + + <:item title="Plan">Pro + <:item title="Status">Active + <:item title="Actions"> + + <.action class="button button--sm">Edit + <.action class="button button--sm button--red">Suspend + + + + """ + end + + def api_items_code do + ~S""" + <.data_list + class="data-list" + items={ + Corex.Item.new([ + %{title: "Repository", value: "corex-ui/corex"}, + %{title: "Visibility", value: "Public"}, + %{title: "Default branch", value: "main"}, + %{title: "Last commit", value: "3 minutes ago"} + ]) + } + /> + """ + end + + def api_items_example(assigns) do + ~H""" + <.data_list + class="data-list" + items={ + Corex.Item.new([ + %{title: "Repository", value: "corex-ui/corex"}, + %{title: "Visibility", value: "Public"}, + %{title: "Default branch", value: "main"}, + %{title: "Last commit", value: "3 minutes ago"} + ]) + } + /> + """ + end +end diff --git a/e2e/lib/e2e_web/demos/data_table_demo.ex b/e2e/lib/e2e_web/demos/data_table_demo.ex new file mode 100644 index 00000000..e2ec8de0 --- /dev/null +++ b/e2e/lib/e2e_web/demos/data_table_demo.ex @@ -0,0 +1,339 @@ +defmodule E2eWeb.Demos.DataTableDemo do + use E2eWeb, :html + + @anatomy_rows [ + %{id: 1, name: "Alice", role: "Admin", email: "alice@example.com"}, + %{id: 2, name: "Bob", role: "User", email: "bob@example.com"}, + %{id: 3, name: "Charlie", role: "Editor", email: "charlie@example.com"} + ] + + def anatomy_minimal_code do + ~S""" + <.data_table + id="data-table-anatomy-minimal" + class="data-table" + rows={@rows} + > + <:col :let={row} label="ID">{row.id} + <:col :let={row} label="Name">{row.name} + <:col :let={row} label="Role">{row.role} + <:col :let={row} label="Email">{row.email} + + """ + end + + def anatomy_minimal_example(assigns) do + assigns = assign(assigns, :rows, @anatomy_rows) + + ~H""" + <.data_table + id="data-table-anatomy-minimal" + class="data-table" + rows={@rows} + > + <:col :let={row} label="ID">{row.id} + <:col :let={row} label="Name">{row.name} + <:col :let={row} label="Role">{row.role} + <:col :let={row} label="Email">{row.email} + + """ + end + + def anatomy_with_action_code do + ~S""" + <.data_table + id="data-table-anatomy-with-action" + class="data-table" + rows={@rows} + > + <:col :let={row} label="ID">{row.id} + <:col :let={row} label="Name">{row.name} + <:col :let={row} label="Role">{row.role} + <:col :let={row} label="Email">{row.email} + <:action :let={row}> + <.action class="button button--sm" aria-label={"Edit #{row.name}"}> + <.heroicon name="hero-pencil-square" /> + + + + """ + end + + def anatomy_with_action_example(assigns) do + assigns = assign(assigns, :rows, @anatomy_rows) + + ~H""" + <.data_table + id="data-table-anatomy-with-action" + class="data-table" + rows={@rows} + > + <:col :let={row} label="ID">{row.id} + <:col :let={row} label="Name">{row.name} + <:col :let={row} label="Role">{row.role} + <:col :let={row} label="Email">{row.email} + <:action :let={row}> + <.action class="button button--sm" aria-label={"Edit #{row.name}"}> + <.heroicon name="hero-pencil-square" /> + + + + """ + end + + def anatomy_empty_code do + ~S""" + <.data_table + id="data-table-anatomy-empty" + class="data-table" + rows={[]} + > + <:col :let={row} label="ID">{row.id} + <:col :let={row} label="Name">{row.name} + <:empty> +

No rows

+ + + """ + end + + def anatomy_empty_example(assigns) do + ~H""" + <.data_table + id="data-table-anatomy-empty" + class="data-table" + rows={[]} + > + <:col :let={row} label="ID">{row.id} + <:col :let={row} label="Name">{row.name} + <:empty> +

No rows

+ + + """ + end + + def patterns_stream_heex do + ~S""" + <.data_table id="pattern-stream-table" class="data-table" rows={@streams.pattern_stream}> + <:col :let={{_id, row}} label="ID">{row.id} + <:col :let={{_id, row}} label="Name">{row.name} + <:col :let={{_id, row}} label="Category">{row.category} + <:empty> +

No items

+ + <:action :let={{dom_id, row}}> + <.action + phx-click="pattern_stream_delete" + phx-value-dom_id={dom_id} + class="button button--sm button--alert" + aria-label={"Delete #{row.name}"} + > + <.heroicon name="hero-trash" /> + + + + """ + end + + def patterns_stream_elixir do + ~S""" + @stream_items [ + %{id: "1", name: "Apple", category: "Fruit"}, + %{id: "2", name: "Banana", category: "Fruit"} + ] + + def mount(_params, _session, socket) do + {:ok, + socket + |> stream(:pattern_stream, @stream_items) + |> assign(:pattern_stream_next_id, 3)} + end + + def handle_event("pattern_stream_add", _params, socket) do + id = to_string(socket.assigns.pattern_stream_next_id) + item = %{id: id, name: "New", category: "Misc"} + {:noreply, + socket + |> stream_insert(:pattern_stream, item) + |> assign(:pattern_stream_next_id, socket.assigns.pattern_stream_next_id + 1)} + end + + def handle_event("pattern_stream_delete", %{"dom_id" => dom_id}, socket) do + {:noreply, stream_delete_by_dom_id(socket, :pattern_stream, dom_id)} + end + """ + end + + def patterns_sort_heex do + ~S""" + <.data_table + id="pattern-sort-table" + class="data-table" + rows={@pattern_sort_rows} + sort_by={@pattern_sort_by} + sort_order={@pattern_sort_order} + on_sort="pattern_sort" + > + <:sort_icon :let={%{direction: direction}}> + <.heroicon name={ + case direction do + :asc -> "hero-chevron-up" + :desc -> "hero-chevron-down" + :none -> "hero-chevron-up-down" + end + } /> + + <:col :let={row} label="ID" name={:id}>{row.id} + <:col :let={row} label="Name" name={:name}>{row.name} + + """ + end + + def patterns_sort_elixir do + ~S""" + def mount(_params, _session, socket) do + rows = [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}] + sorted = E2eWeb.DataTablePatternState.sort_rows(rows, :id, :asc) + + {:ok, + socket + |> assign(:pattern_sort_rows, sorted) + |> assign(:pattern_sort_by, :id) + |> assign(:pattern_sort_order, :asc)} + end + + def handle_event("pattern_sort", %{"sort_by" => _} = params, socket) do + {:noreply, + E2eWeb.DataTablePatternState.handle_sort_ns(socket, params, + rows: :pattern_sort_rows, + sort_by: :pattern_sort_by, + sort_order: :pattern_sort_order + )} + end + """ + end + + def patterns_select_heex do + ~S""" + <.data_table + id="pattern-select-table" + class="data-table" + rows={@pattern_select_rows} + row_id={&"pselect-#{&1.id}"} + selectable + selected={@pattern_select_selected} + on_select="pattern_select" + on_select_all="pattern_select_all" + checkbox_class="checkbox" + > + <:checkbox_indicator> + <.heroicon name="hero-check" /> + + <:col :let={row} label="ID" name={:id}>{row.id} + <:col :let={row} label="Name" name={:name}>{row.name} + + """ + end + + def patterns_select_elixir do + ~S""" + def mount(_params, _session, socket) do + {:ok, assign(socket, :pattern_select_selected, []) |> assign(:pattern_select_rows, fetch_rows())} + end + + def handle_event("pattern_select", params, socket) do + {:noreply, + E2eWeb.DataTablePatternState.handle_select_ns(socket, params, + rows: :pattern_select_rows, + selected: :pattern_select_selected, + table_id: "pattern-select-table" + )} + end + + def handle_event("pattern_select_all", params, socket) do + {:noreply, + E2eWeb.DataTablePatternState.handle_select_all_ns(socket, params, + rows: :pattern_select_rows, + selected: :pattern_select_selected, + table_id: "pattern-select-table", + row_id: &"pselect-#{&1.id}" + )} + end + """ + end + + def patterns_full_heex do + ~S""" + <.data_table + id="pattern-full-table" + class="data-table" + rows={@pattern_full_rows} + row_id={&"pfull-#{&1.id}"} + sort_by={@pattern_full_sort_by} + sort_order={@pattern_full_sort_order} + on_sort="pattern_full_sort" + selectable + selected={@pattern_full_selected} + on_select="pattern_full_select" + on_select_all="pattern_full_select_all" + checkbox_class="checkbox" + > + <:checkbox_indicator> + <.heroicon name="hero-check" /> + + <:sort_icon :let={%{direction: direction}}> + <.heroicon name={ + case direction do + :asc -> "hero-chevron-up" + :desc -> "hero-chevron-down" + :none -> "hero-chevron-up-down" + end + } /> + + <:col :let={row} label="ID" name={:id}>{row.id} + <:col :let={row} label="Name" name={:name}>{row.name} + <:action :let={row}> + <.action class="button button--sm" aria-label={"Edit #{row.name}"}> + <.heroicon name="hero-pencil-square" /> + + + <:empty> +

No rows

+ + + """ + end + + def patterns_full_elixir do + ~S""" + def handle_event("pattern_full_sort", %{"sort_by" => _} = p, socket) do + {:noreply, + E2eWeb.DataTablePatternState.handle_sort_ns(socket, p, + rows: :pattern_full_rows, + sort_by: :pattern_full_sort_by, + sort_order: :pattern_full_sort_order + )} + end + + def handle_event("pattern_full_select", params, socket) do + {:noreply, + E2eWeb.DataTablePatternState.handle_select_ns(socket, params, + rows: :pattern_full_rows, + selected: :pattern_full_selected, + table_id: "pattern-full-table" + )} + end + + def handle_event("pattern_full_select_all", params, socket) do + {:noreply, + E2eWeb.DataTablePatternState.handle_select_all_ns(socket, params, + rows: :pattern_full_rows, + selected: :pattern_full_selected, + table_id: "pattern-full-table", + row_id: &"pfull-#{&1.id}" + )} + end + """ + end +end diff --git a/e2e/lib/e2e_web/demos/date_picker_demo.ex b/e2e/lib/e2e_web/demos/date_picker_demo.ex new file mode 100644 index 00000000..6cf31a52 --- /dev/null +++ b/e2e/lib/e2e_web/demos/date_picker_demo.ex @@ -0,0 +1,904 @@ +defmodule E2eWeb.Demos.DatePickerDemo do + use E2eWeb, :html + + def minimal_code do + ~S""" + <.date_picker id="date-picker-anatomy-minimal" trigger_aria_label="Select date" input_aria_label="Select date" class="date-picker"> + <:label>Select a date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def minimal_example(assigns) do + ~H""" + <.date_picker + id="date-picker-anatomy-minimal" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Select a date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def anatomy_range_code do + ~S""" + <.date_picker + id="date-picker-anatomy-range" + selection_mode="range" + value="2024-06-01,2024-06-15" + trigger_aria_label="Select date range" + input_aria_label="Date range" + class="date-picker" + > + <:label>Range + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def anatomy_range_example(assigns) do + ~H""" + <.date_picker + id="date-picker-anatomy-range" + selection_mode="range" + value="2024-06-01,2024-06-15" + trigger_aria_label="Select date range" + input_aria_label="Date range" + class="date-picker" + > + <:label>Range + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def anatomy_multiple_code do + ~S""" + <.date_picker + id="date-picker-anatomy-multiple" + selection_mode="multiple" + max_selected_dates={3} + value="2024-06-03,2024-06-10,2024-06-17" + trigger_aria_label="Select dates" + input_aria_label="Dates" + class="date-picker" + > + <:label>Multiple + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def anatomy_multiple_example(assigns) do + ~H""" + <.date_picker + id="date-picker-anatomy-multiple" + selection_mode="multiple" + max_selected_dates={3} + value="2024-06-03,2024-06-10,2024-06-17" + trigger_aria_label="Select dates" + input_aria_label="Dates" + class="date-picker" + > + <:label>Multiple + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def api_set_value_client_binding_code do + ~S""" + <.action phx-click={Corex.DatePicker.set_value("date-picker-api-sv-client", "2024-01-15")} class="button button--sm"> + Set to 2024-01-15 + + <.action phx-click={Corex.DatePicker.set_value("date-picker-api-sv-client", "2024-12-25")} class="button button--sm"> + Set to 2024-12-25 + + + <.date_picker + id="date-picker-api-sv-client" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Select a date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def api_set_value_client_binding_example(assigns) do + ~H""" +
+ <.action + phx-click={Corex.DatePicker.set_value("date-picker-api-sv-client", "2024-01-15")} + class="button button--sm" + > + Set to 2024-01-15 + + <.action + phx-click={Corex.DatePicker.set_value("date-picker-api-sv-client", "2024-12-25")} + class="button button--sm" + > + Set to 2024-12-25 + +
+ + <.date_picker + id="date-picker-api-sv-client" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Select a date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def api_set_value_client_js_heex do + ~S""" + <.action + phx-click={JS.dispatch("corex:date-picker:set-value", + to: "#date-picker-api-sv-js", + detail: %{value: "2024-01-15"}, + bubbles: false + )} + class="button button--sm" + > + Set to 2024-01-15 + + """ + end + + def api_set_value_client_js_js do + ~S""" + const el = document.getElementById("date-picker-api-sv-js"); + if (!el) return; + el.dispatchEvent( + new CustomEvent("corex:date-picker:set-value", { bubbles: false, detail: { value: "2024-12-25" } }) + ); + """ + end + + def api_set_value_client_js_ts do + ~S""" + const el = document.getElementById("date-picker-api-sv-js"); + if (!el) return; + el.dispatchEvent( + new CustomEvent<{ value: string }>("corex:date-picker:set-value", { + bubbles: false, + detail: { value: "2024-12-25" } + }) + ); + """ + end + + def api_set_value_client_js_example(assigns) do + ~H""" +
+ <.action + phx-click={ + JS.dispatch("corex:date-picker:set-value", + to: "#date-picker-api-sv-js", + detail: %{value: "2024-01-15"}, + bubbles: false + ) + } + class="button button--sm" + > + Set to 2024-01-15 + + <.action + phx-click={ + JS.dispatch("corex:date-picker:set-value", + to: "#date-picker-api-sv-js", + detail: %{value: "2024-12-25"}, + bubbles: false + ) + } + class="button button--sm" + > + Set to 2024-12-25 + +
+ <.date_picker + id="date-picker-api-sv-js" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Select a date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def api_set_value_server_heex do + ~S""" + <.action phx-click="date_picker_api_set_value" phx-value-date="2024-01-15" class="button button--sm"> + Set to 2024-01-15 + + + <.date_picker id="date-picker-api-sv-server" trigger_aria_label="Select date" input_aria_label="Select date" class="date-picker"> + <:label>Select a date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def api_set_value_server_elixir do + ~S""" + def handle_event("date_picker_api_set_value", %{"date" => value}, socket) do + {:noreply, Corex.DatePicker.set_value(socket, "date-picker-api-sv-server", value)} + end + """ + end + + def api_set_value_server_example(assigns) do + ~H""" +
+ <.action + phx-click="date_picker_api_set_value" + phx-value-date="2024-01-15" + class="button button--sm" + > + Set to 2024-01-15 + + <.action + phx-click="date_picker_api_set_value" + phx-value-date="2024-12-25" + class="button button--sm" + > + Set to 2024-12-25 + +
+ <.date_picker + id="date-picker-api-sv-server" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Select a date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def events_on_value_server_heex do + ~S""" + <.date_picker + id="date-picker-e-sv" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + on_value_change="dpe_on_value_server" + > + <:label>Select a date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def events_on_value_server_elixir do + ~S""" + def handle_event("dpe_on_value_server", %{"id" => id, "value" => value}, socket) do + log = new_log("server", id, inspect(value)) + {:noreply, stream_insert(socket, :server_value_logs, log, at: 0)} + end + """ + end + + def events_on_open_server_heex do + ~S""" + <.date_picker + id="date-picker-e-so" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + on_open_change="dpe_on_open_server" + > + <:label>Select a date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def events_on_open_server_elixir do + ~S""" + def handle_event("dpe_on_open_server", %{"id" => id, "open" => open}, socket) do + log = new_log("server", id, inspect(open)) + {:noreply, stream_insert(socket, :server_open_logs, log, at: 0)} + end + """ + end + + def events_on_value_client_heex do + ~S""" + <.date_picker + id="date-picker-e-cv" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + on_value_change_client="date-picker-value-changed" + > + <:label>Select a date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def events_on_value_client_js do + ~S""" + const el = document.getElementById("date-picker-e-cv"); + if (!el) return; + el.addEventListener("date-picker-value-changed", (e) => { + const d = e.detail; + this.pushEvent("dpe_on_value_client", { id: d.id, value: d.value }); + }); + """ + end + + def events_on_value_client_ts, do: events_on_value_client_js() + + def events_on_open_client_heex do + ~S""" + <.date_picker + id="date-picker-e-co" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + on_open_change_client="date-picker-open-changed" + > + <:label>Select a date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def events_on_open_client_js do + ~S""" + const el = document.getElementById("date-picker-e-co"); + if (!el) return; + el.addEventListener("date-picker-open-changed", (e) => { + const d = e.detail; + this.pushEvent("dpe_on_open_client", { id: d.id, open: d.open }); + }); + """ + end + + def events_on_open_client_ts, do: events_on_open_client_js() + + def patterns_controlled_code do + ~S""" + <.date_picker + id="date-picker-patterns-controlled" + class="date-picker" + controlled + value={@selected && [@selected]} + on_value_change="pattern_date_changed" + trigger_aria_label="Select date" + input_aria_label="Select date" + > + <:label>Date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def patterns_controlled_elixir do + ~S""" + def handle_event("pattern_date_changed", %{"value" => v}, socket) do + {:noreply, assign(socket, :date, v)} + end + """ + end + + def form_ecto do + ~S""" + defmodule E2e.Form.DatePickerForm do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :date, :date + end + + def changeset(form, attrs \\ %{}) do + form + |> cast(attrs, [:date]) + end + + def changeset_validate(form, attrs \\ %{}) do + form + |> cast(attrs, [:date]) + |> validate_required([:date], message: "can't be blank") + end + end + """ + end + + def form_doc_controller_changeset_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/date-picker/form"} + method="post" + id={@form.id} + > + <.date_picker + field={f[:date]} + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + id="date-picker-form-changeset-input" + > + <:label>Date + <:trigger> + <.heroicon name="hero-calendar" class="icon" /> + + <:prev_trigger> + <.heroicon name="hero-chevron-left" class="icon" /> + + <:next_trigger> + <.heroicon name="hero-chevron-right" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" class="button button--accent" id="date-picker-changeset-form-submit">Submit + + """ + end + + def form_doc_controller_changeset_elixir do + ~S""" + def date_picker_form_page(conn, _params) do + form = + %E2e.Form.DatePickerForm{} + |> E2e.Form.DatePickerForm.changeset(%{}) + |> Phoenix.Component.to_form(as: :date_picker_changeset, id: "date-picker-changeset-form") + + validate_form = + %E2e.Form.DatePickerForm{} + |> E2e.Form.DatePickerForm.changeset_validate(%{}) + |> Phoenix.Component.to_form(as: :date_picker_validate, id: "date-picker-validate-form") + + conn + |> assign_date_picker_form_docs(nil) + |> render(:date_picker_form_page, form: form, validate_form: validate_form) + end + """ + end + + def form_doc_controller_validate_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/date-picker/form"} + method="post" + id={@form.id} + > + <.date_picker + field={f[:date]} + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + id="date-picker-form-validate-input" + > + <:label>Date (required) + <:trigger> + <.heroicon name="hero-calendar" class="icon" /> + + <:prev_trigger> + <.heroicon name="hero-chevron-left" class="icon" /> + + <:next_trigger> + <.heroicon name="hero-chevron-right" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" class="button button--accent" id="date-picker-validate-form-submit">Submit + + """ + end + + def form_doc_controller_validate_elixir do + ~S""" + def date_picker_form_page(conn, _params) do + validate_form = + %E2e.Form.DatePickerForm{} + |> E2e.Form.DatePickerForm.changeset_validate(%{}) + |> Phoenix.Component.to_form(as: :date_picker_validate, id: "date-picker-validate-form") + # ... + end + """ + end + + def form_doc_native_heex do + ~S""" +
+ + <.date_picker + name="date_picker_form[date]" + id="date-picker-form-native" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Date + <:trigger> + <.heroicon name="hero-calendar" class="icon" /> + + <:prev_trigger> + <.heroicon name="hero-chevron-left" class="icon" /> + + <:next_trigger> + <.heroicon name="hero-chevron-right" class="icon" /> + + + <.action type="submit" class="button button--accent" id="date-picker-form-native-submit">Submit +
+ """ + end + + def form_doc_live_changeset_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate_basic" + phx-submit="save_basic" + > + <.date_picker + id="date-picker-basic-live" + field={@form[:date]} + controlled + value={@date_display} + on_value_change="date_changed_basic" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Date + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" class="button button--accent">Submit + + """ + end + + def form_doc_live_changeset_elixir do + ~S""" + def handle_event("date_changed_basic", %{"value" => value}, socket) do + params = %{"date" => value} + changeset = E2e.Form.DatePickerForm.changeset(%E2e.Form.DatePickerForm{}, params) |> Map.put(:action, :validate) + {:noreply, assign(socket, :basic_form, to_form(changeset, as: :date_picker_basic, id: "date-picker-basic-form"))} + end + """ + end + + def form_doc_live_validate_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate_validate" + phx-submit="save_validate" + > + <.date_picker + id="date-picker-validate-live" + field={@form[:date]} + controlled + value={@date_display} + on_value_change="date_changed_validate" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Date (required) + <:trigger><.heroicon name="hero-calendar" class="icon" /> + <:prev_trigger><.heroicon name="hero-chevron-left" class="icon" /> + <:next_trigger><.heroicon name="hero-chevron-right" class="icon" /> + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" class="button button--accent">Submit + + """ + end + + def form_doc_live_validate_elixir do + ~S""" + def handle_event("date_changed_validate", %{"value" => value}, socket) do + params = %{"date" => value} + changeset = + E2e.Form.DatePickerForm.changeset_validate(%E2e.Form.DatePickerForm{}, params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :validate_form, to_form(changeset, as: :date_picker_validate, id: "date-picker-validate-form-live"))} + end + """ + end + + def form_code do + form_doc_controller_changeset_heex() + end + + attr(:form, Phoenix.HTML.Form, required: true) + + def form_preview_controller_changeset(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/date-picker/form"} + method="post" + id={@form.id} + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.date_picker + field={f[:date]} + id="date-picker-form-changeset-input" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Date + <:trigger> + <.heroicon name="hero-calendar" class="icon" /> + + <:prev_trigger> + <.heroicon name="hero-chevron-left" class="icon" /> + + <:next_trigger> + <.heroicon name="hero-chevron-right" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action + type="submit" + id="date-picker-changeset-form-submit" + class="button button--accent w-full" + > + Submit + + + """ + end + + attr(:form, Phoenix.HTML.Form, required: true) + + def form_preview_controller_validate(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/date-picker/form"} + method="post" + id={@form.id} + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.date_picker + field={f[:date]} + id="date-picker-form-validate-input" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Date (required) + <:trigger> + <.heroicon name="hero-calendar" class="icon" /> + + <:prev_trigger> + <.heroicon name="hero-chevron-left" class="icon" /> + + <:next_trigger> + <.heroicon name="hero-chevron-right" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action + type="submit" + id="date-picker-validate-form-submit" + class="button button--accent w-full" + > + Submit + + + """ + end + + def form_preview_controller_native(assigns) do + _ = assigns + + ~H""" +
+ + <.date_picker + name="date_picker_form[date]" + id="date-picker-form-native" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Date + <:trigger> + <.heroicon name="hero-calendar" class="icon" /> + + <:prev_trigger> + <.heroicon name="hero-chevron-left" class="icon" /> + + <:next_trigger> + <.heroicon name="hero-chevron-right" class="icon" /> + + + <.action + type="submit" + id="date-picker-form-native-submit" + class="button button--accent w-full" + > + Submit + +
+ """ + end + + attr(:form, Phoenix.HTML.Form, required: true) + attr(:date_display, :any, required: true) + + def form_preview_live_changeset(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate_basic" + phx-submit="save_basic" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.date_picker + id="date-picker-basic-live" + field={@form[:date]} + controlled + value={@date_display} + on_value_change="date_changed_basic" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Date + <:trigger> + <.heroicon name="hero-calendar" class="icon" /> + + <:prev_trigger> + <.heroicon name="hero-chevron-left" class="icon" /> + + <:next_trigger> + <.heroicon name="hero-chevron-right" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action + type="submit" + id="date-picker-basic-form-live-submit" + class="button button--accent w-full" + > + Submit + + + """ + end + + attr(:form, Phoenix.HTML.Form, required: true) + attr(:date_display, :any, required: true) + + def form_preview_live_validate(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate_validate" + phx-submit="save_validate" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.date_picker + id="date-picker-validate-live" + field={@form[:date]} + controlled + value={@date_display} + on_value_change="date_changed_validate" + trigger_aria_label="Select date" + input_aria_label="Select date" + class="date-picker" + > + <:label>Date (required) + <:trigger> + <.heroicon name="hero-calendar" class="icon" /> + + <:prev_trigger> + <.heroicon name="hero-chevron-left" class="icon" /> + + <:next_trigger> + <.heroicon name="hero-chevron-right" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action + type="submit" + id="date-picker-validate-form-live-submit" + class="button button--accent w-full" + > + Submit + + + """ + end +end diff --git a/e2e/lib/e2e_web/demos/dialog_demo.ex b/e2e/lib/e2e_web/demos/dialog_demo.ex new file mode 100644 index 00000000..e229622c --- /dev/null +++ b/e2e/lib/e2e_web/demos/dialog_demo.ex @@ -0,0 +1,386 @@ +defmodule E2eWeb.Demos.DialogDemo do + use E2eWeb, :html + + def minimal_code do + ~S""" + <.dialog id="dialog-anatomy-minimal" class="dialog"> + <:trigger>Open + <:content> +

Minimal content.

+ + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + + """ + end + + def minimal_example(assigns) do + ~H""" + <.dialog id="dialog-anatomy-minimal" class="dialog"> + <:trigger>Open + <:content> +

Minimal content.

+ + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + + """ + end + + def with_title_description_code do + ~S""" + <.dialog id="dialog-anatomy-titled" class="dialog"> + <:trigger>Open Dialog + <:title>Dialog Title + <:description> + Short description of what this dialog is for. + + <:content> +

Body content.

+ + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + + """ + end + + def with_title_description_example(assigns) do + ~H""" + <.dialog id="dialog-anatomy-titled" class="dialog"> + <:trigger>Open Dialog + <:title>Dialog Title + <:description> + Short description of what this dialog is for. + + <:content> +

Body content.

+ + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + + """ + end + + def actions_code do + ~S""" + <.dialog id="dialog-anatomy-actions" class="dialog"> + <:trigger>Open Dialog + <:title>Confirm + <:description>Choose an action to continue. + <:content> +

Are you sure you want to continue?

+
+ <.action phx-click={Corex.Dialog.set_open("dialog-anatomy-actions", false)} class="button button--sm button--ghost"> + Cancel + + <.action phx-click={Corex.Dialog.set_open("dialog-anatomy-actions", false)} class="button button--sm"> + Continue + +
+ + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + + """ + end + + def actions_example(assigns) do + ~H""" + <.dialog id="dialog-anatomy-actions" class="dialog"> + <:trigger>Open Dialog + <:title>Confirm + <:description>Choose an action to continue. + <:content> +

Are you sure you want to continue?

+
+ <.action + phx-click={Corex.Dialog.set_open("dialog-anatomy-actions", false)} + class="button button--sm button--ghost" + > + Cancel + + <.action + phx-click={Corex.Dialog.set_open("dialog-anatomy-actions", false)} + class="button button--sm" + > + Continue + +
+ + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + + """ + end + + def api_client_binding_code do + ~S""" +
+ <.action phx-click={Corex.Dialog.set_open("dialog-api", true)} class="button button--sm"> + Open Dialog + +
+ + <.dialog id="dialog-api" class="dialog"> + <:trigger>Open Dialog + <:title>Dialog Title + <:description>Dialog description. + <:content> +

Dialog content

+ <.action phx-click={Corex.Dialog.set_open("dialog-api", false)} class="button button--sm"> + Close + + + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + + """ + end + + def api_client_binding_example(assigns) do + ~H""" +
+ <.action phx-click={Corex.Dialog.set_open("dialog-api", true)} class="button button--sm"> + Open Dialog + +
+ + <.dialog id="dialog-api" class="dialog"> + <:trigger>Open Dialog + <:title>Dialog Title + <:description>Dialog description. + <:content> +

Dialog content

+ <.action phx-click={Corex.Dialog.set_open("dialog-api", false)} class="button button--sm"> + Close + + + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + + """ + end + + def events_server_heex do + ~S""" + <.dialog id="dialog-events" class="dialog" on_open_change="dialog_open_changed" on_open_change_client="dialog-open-changed"> + <:trigger>Open Dialog + <:title>Dialog Title + <:content> +

Dialog content

+ + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + + """ + end + + def patterns_controlled_heex do + ~S""" + <.dialog + id="patterns-controlled-dialog" + class="dialog" + controlled + open={@dialog_open} + on_open_change="patterns_dialog_open_changed" + > + <:trigger>Open dialog + <:title>Controlled + <:description>State lives on the LiveView. + <:content> +

Content

+ + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + + """ + end + + def patterns_controlled_elixir do + ~S""" + def handle_event("patterns_dialog_open_changed", %{"open" => open}, socket) do + {:noreply, assign(socket, :dialog_open, open)} + end + """ + end + + def animation_instant_heex do + ~S""" + <.dialog + id="dialog-animate-instant" + class="dialog" + modal + animation="instant" + > + <:trigger>Open + <:title>Instant + <:content> +

Native show and hide without JS transitions.

+ + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + + """ + end + + def animation_custom_heex do + ~S""" + <.dialog + id="dialog-custom-animate" + class="dialog" + modal + animation="custom" + on_open_change_client="my-dialog-open-changed" + > + <:trigger>Open + <:title>Custom + <:content> +

Motion animates open and close.

+ + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + + """ + end + + def styling_size_code do + ~S""" + <.dialog id="dialog-style-sm" class="dialog dialog--sm" modal> + <:trigger>Open (sm) + <:title>Small + <:content>

Content

+ <:close_trigger><.heroicon name="hero-x-mark" class="icon" /> + + <.dialog id="dialog-style-md" class="dialog dialog--md" modal> + <:trigger>Open (md) + <:title>Medium + <:content>

Content

+ <:close_trigger><.heroicon name="hero-x-mark" class="icon" /> + + <.dialog id="dialog-style-lg" class="dialog dialog--lg" modal> + <:trigger>Open (lg) + <:title>Large + <:content>

Content

+ <:close_trigger><.heroicon name="hero-x-mark" class="icon" /> + + <.dialog id="dialog-style-xl" class="dialog dialog--xl" modal> + <:trigger>Open (xl) + <:title>Extra large + <:content>

Content

+ <:close_trigger><.heroicon name="hero-x-mark" class="icon" /> + + """ + end + + def styling_size_example(assigns) do + ~H""" +
+ <.dialog id="dialog-style-sm" class="dialog dialog--sm" modal> + <:trigger>Open (sm) + <:title>Small + <:content> +

Content

+ + <:close_trigger><.heroicon name="hero-x-mark" class="icon" /> + + <.dialog id="dialog-style-md" class="dialog dialog--md" modal> + <:trigger>Open (md) + <:title>Medium + <:content> +

Content

+ + <:close_trigger><.heroicon name="hero-x-mark" class="icon" /> + + <.dialog id="dialog-style-lg" class="dialog dialog--lg" modal> + <:trigger>Open (lg) + <:title>Large + <:content> +

Content

+ + <:close_trigger><.heroicon name="hero-x-mark" class="icon" /> + + <.dialog id="dialog-style-xl" class="dialog dialog--xl" modal> + <:trigger>Open (xl) + <:title>Extra large + <:content> +

Content

+ + <:close_trigger><.heroicon name="hero-x-mark" class="icon" /> + +
+ """ + end + + def styling_sidebar_code do + ~S""" + <.dialog id="dialog-style-sidebar" class="dialog dialog--sidebar" modal> + <:trigger>Open sidebar + <:title>Sidebar + <:content>

Edge-aligned panel.

+ <:close_trigger><.heroicon name="hero-x-mark" class="icon" /> + + """ + end + + def styling_sidebar_example(assigns) do + ~H""" + <.dialog id="dialog-style-sidebar" class="dialog dialog--sidebar" modal> + <:trigger>Open sidebar + <:title>Sidebar + <:content> +

Edge-aligned panel.

+ + <:close_trigger><.heroicon name="hero-x-mark" class="icon" /> + + """ + end + + def animation_custom_js do + ~S""" + import { animate } from "motion" + + document.addEventListener("my-dialog-open-changed", (e) => { + const { id, open } = e.detail + const root = document.getElementById(id) + if (!root) return + const backdrop = root.querySelector('[data-scope="dialog"][data-part="backdrop"]') + const content = root.querySelector('[data-scope="dialog"][data-part="content"]') + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches + if (reduced) { + if (backdrop) backdrop.style.opacity = open ? "" : "0" + if (content) content.style.opacity = open ? "" : "0" + return + } + if (open) { + if (backdrop) animate(backdrop, { opacity: [0, 1] }, { duration: 0.5, easing: "ease-out" }) + if (content) + animate( + content, + { opacity: [0, 1], scale: [0.7, 1], y: [60, 0], filter: ["blur(12px)", "blur(0px)"] }, + { duration: 0.7, easing: [0.16, 1, 0.3, 1] }, + ) + } else { + if (backdrop) animate(backdrop, { opacity: [1, 0] }, { duration: 0.4, easing: "ease-in" }) + if (content) + animate( + content, + { opacity: [1, 0], scale: [1, 0.8], y: [0, 40], filter: ["blur(0px)", "blur(12px)"] }, + { duration: 0.35, easing: "ease-in" }, + ) + } + }) + """ + end +end diff --git a/e2e/lib/e2e_web/demos/editable_demo.ex b/e2e/lib/e2e_web/demos/editable_demo.ex new file mode 100644 index 00000000..17e028ba --- /dev/null +++ b/e2e/lib/e2e_web/demos/editable_demo.ex @@ -0,0 +1,502 @@ +defmodule E2eWeb.Demos.EditableDemo do + use E2eWeb, :html + + def minimal_code do + ~S""" + <.editable id="editable-anatomy-minimal" class="editable" value="My custom value" placeholder="Enter value"> + <:label>Name + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + + """ + end + + def minimal_example(assigns) do + ~H""" + <.editable + id="editable-anatomy-minimal" + class="editable" + value="My custom value" + placeholder="Enter value" + > + <:label>Name + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + + """ + end + + def with_triggers_code do + ~S""" + <.editable + id="editable-anatomy-triggers" + class="editable" + value="Double click to edit" + activation_mode="dblclick" + select_on_focus + > + <:label>Name + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + + """ + end + + def with_triggers_example(assigns) do + ~H""" + <.editable + id="editable-anatomy-triggers" + class="editable" + value="Double click to edit" + activation_mode="dblclick" + select_on_focus + > + <:label>Name + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + + """ + end + + def styling_size_code do + ~S""" + <.editable id="editable-style-sm" class="editable editable--sm" value="SM"> + <:label>Label + <:edit_trigger><.heroicon name="hero-pencil-square" /> + <:submit_trigger><.heroicon name="hero-check" /> + <:cancel_trigger><.heroicon name="hero-x-mark" /> + + <.editable id="editable-style-lg" class="editable editable--lg" value="LG"> + <:label>Label + <:edit_trigger><.heroicon name="hero-pencil-square" /> + <:submit_trigger><.heroicon name="hero-check" /> + <:cancel_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def styling_size_example(assigns) do + ~H""" +
+ <.editable id="editable-style-sm" class="editable editable--sm" value="SM"> + <:label>Label + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + + <.editable id="editable-style-lg" class="editable editable--lg" value="LG"> + <:label>Label + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + +
+ """ + end + + def api_set_value_client_binding_heex do + ~S""" +
+ <.action phx-click={Corex.Editable.set_value("editable-api-cb", "Alpha")} class="button button--sm">Alpha + <.action phx-click={Corex.Editable.set_value("editable-api-cb", "Beta")} class="button button--sm">Beta +
+ <.editable id="editable-api-cb" class="editable" default_value="Start"> + <:label>Label + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + + """ + end + + def api_set_value_client_binding_example(assigns) do + _ = assigns + + ~H""" +
+ <.action + phx-click={Corex.Editable.set_value("editable-api-cb", "Alpha")} + class="button button--sm" + > + Alpha + + <.action + phx-click={Corex.Editable.set_value("editable-api-cb", "Beta")} + class="button button--sm" + > + Beta + +
+ <.editable id="editable-api-cb" class="editable" default_value="Start"> + <:label>Label + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + + """ + end + + def api_set_value_client_js_heex do + ~S""" +
+ +
+ <.editable id="editable-api-cjs" class="editable" default_value="Start"> + <:label>Label + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + + """ + end + + def api_set_value_client_js_js do + ~S""" + const el = document.getElementById("editable-api-cjs"); + el?.dispatchEvent( + new CustomEvent("corex:editable:set-value", { bubbles: false, detail: { value: "Gamma" } }) + ); + """ + end + + def api_set_value_client_js_ts do + ~S""" + const el: HTMLElement | null = document.getElementById("editable-api-cjs"); + el?.dispatchEvent( + new CustomEvent("corex:editable:set-value", { bubbles: false, detail: { value: "Gamma" } }) + ); + """ + end + + def api_set_value_client_js_example(assigns) do + _ = assigns + + ~H""" +
+
+ +
+ <.editable id="editable-api-cjs" class="editable" default_value="Start"> + <:label>Label + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + +
+ """ + end + + def api_set_value_server_heex do + ~S""" +
+ <.action phx-click="editable_api_alpha" class="button button--sm">Alpha + <.action phx-click="editable_api_beta" class="button button--sm">Beta +
+ <.editable id="editable-api-srv" class="editable" default_value="Start"> + <:label>Label + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + + """ + end + + def api_set_value_server_elixir do + ~S""" + def handle_event("editable_api_alpha", _params, socket) do + {:noreply, Corex.Editable.set_value(socket, "editable-api-srv", "Alpha")} + end + + def handle_event("editable_api_beta", _params, socket) do + {:noreply, Corex.Editable.set_value(socket, "editable-api-srv", "Beta")} + end + """ + end + + def api_set_value_server_example(assigns) do + _ = assigns + + ~H""" +
+
+ <.action phx-click="editable_api_alpha" class="button button--sm">Alpha + <.action phx-click="editable_api_beta" class="button button--sm">Beta +
+ <.editable id="editable-api-srv" class="editable" default_value="Start"> + <:label>Label + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + +
+ """ + end + + def api_codes do + %{ + set_value_client_binding: api_set_value_client_binding_heex(), + set_value_client_js_heex: api_set_value_client_js_heex(), + set_value_client_js: api_set_value_client_js_js(), + set_value_client_ts: api_set_value_client_js_ts(), + set_value_server_heex: api_set_value_server_heex(), + set_value_server_elixir: api_set_value_server_elixir() + } + end + + def form_ecto do + ~S""" + defmodule E2e.Form.EditableForm do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :text, :string + end + + def changeset(form, attrs \\ %{}) do + form + |> cast(attrs, [:text]) + end + end + """ + end + + def form_doc_live_changeset_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.editable + field={@form[:text]} + on_value_change="value_changed" + placeholder="Enter text" + activation_mode="dblclick" + select_on_focus + class="editable" + > + <:label>Text + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + + <.action type="submit" id="editable-form-live-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_live_changeset_elixir do + ~S""" + def mount(_params, _session, socket) do + form = + %E2e.Form.EditableForm{} + |> E2e.Form.EditableForm.changeset(%{}) + |> Phoenix.Component.to_form(as: :editable_form, id: "editable-form") + + {:ok, assign(socket, :form, form)} + end + + def handle_event("validate", %{"editable_form" => params}, socket) do + changeset = + %E2e.Form.EditableForm{} + |> E2e.Form.EditableForm.changeset(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, + action: :validate, + as: :editable_form, + id: "editable-form" + ) + )} + end + + def handle_event("value_changed", %{"value" => value}, socket) do + params = Map.merge(socket.assigns.form.params || %{}, %{"text" => to_string(value)}) + + changeset = + %E2e.Form.EditableForm{} + |> E2e.Form.EditableForm.changeset(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, + action: :validate, + as: :editable_form, + id: "editable-form" + ) + )} + end + + def handle_event("save", %{"editable_form" => params}, socket) do + case E2e.Form.EditableForm.changeset(%E2e.Form.EditableForm{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + _data = Ecto.Changeset.apply_changes(changeset) + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(E2e.Form.EditableForm.changeset(%E2e.Form.EditableForm{}, params), + as: :editable_form, + id: "editable-form" + ) + )} + + changeset -> + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, + action: :insert, + as: :editable_form, + id: "editable-form" + ) + )} + end + end + """ + end + + def form_preview_live_changeset(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.editable + field={@form[:text]} + on_value_change="value_changed" + placeholder="Enter text" + activation_mode="dblclick" + select_on_focus + class="editable" + > + <:label>Text + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + + <.action type="submit" id="editable-form-live-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def events_server_heex do + ~S""" + <.editable + id="editable-events-server" + class="editable" + default_value="Edit me" + on_value_change="editable_changed" + > + <:label>Label + <:edit_trigger><.heroicon name="hero-pencil-square" /> + <:submit_trigger><.heroicon name="hero-check" /> + <:cancel_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("editable_changed", %{"id" => id, "value" => value}, socket) do + log = %{time: "12:00:00", source: "server", value: inspect(value)} + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + ~S""" + <.editable + id="editable-events-client" + class="editable" + default_value="Edit me" + on_value_change_client="editable-changed" + > + <:label>Label + <:edit_trigger><.heroicon name="hero-pencil-square" /> + <:submit_trigger><.heroicon name="hero-check" /> + <:cancel_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("editable-events-client"); + el?.addEventListener("editable-changed", (event) => console.log(event.detail)); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById("editable-events-client"); + el?.addEventListener("editable-changed", (event: Event) => + console.log((event as CustomEvent).detail) + ); + """ + end + + def form_code do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/editable/form"} + method="post" + id={@form.id} + > + + <.editable + field={f[:text]} + placeholder="Enter text" + activation_mode="dblclick" + select_on_focus + class="editable" + > + <:label>Text + <:edit_trigger><.heroicon name="hero-pencil-square" class="icon" /> + <:submit_trigger><.heroicon name="hero-check" class="icon" /> + <:cancel_trigger><.heroicon name="hero-x-mark" class="icon" /> + + <.action type="submit" id="editable-form-submit" class="button button--accent"> + Submit + + + """ + end +end diff --git a/e2e/lib/e2e_web/demos/floating_panel_demo.ex b/e2e/lib/e2e_web/demos/floating_panel_demo.ex new file mode 100644 index 00000000..97e051b6 --- /dev/null +++ b/e2e/lib/e2e_web/demos/floating_panel_demo.ex @@ -0,0 +1,320 @@ +defmodule E2eWeb.Demos.FloatingPanelDemo do + use E2eWeb, :html + + def anatomy_basic_code do + ~S""" + <.floating_panel id="floating-panel-anatomy" class="floating-panel"> + <:open_trigger>Close panel + <:closed_trigger>Open panel + <:minimize_trigger> + <.heroicon name="hero-arrow-down-left" class="icon" /> + + <:maximize_trigger> + <.heroicon name="hero-arrows-pointing-out" class="icon" /> + + <:default_trigger> + <.heroicon name="hero-rectangle-stack" class="icon" /> + + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + <:content> +

+ Congue molestie ipsum gravida a. Sed ac eros luctus, cursus turpis + non, pellentesque elit. Pellentesque sagittis fermentum. +

+ + + """ + end + + def api_client_binding_code do + """ +
+ <.action phx-click={Corex.FloatingPanel.set_open("floating-panel-api-bind", true)} class="button button--sm"> + Open + + <.action phx-click={Corex.FloatingPanel.set_open("floating-panel-api-bind", false)} class="button button--sm"> + Close + +
+ + #{fp_api_panel_snippet("floating-panel-api-bind", "Open and close via phx-click and Corex.FloatingPanel.set_open/2.")} + """ + end + + def api_client_binding_example(assigns) do + ~H""" +
+ <.action + phx-click={Corex.FloatingPanel.set_open("floating-panel-api-bind", true)} + class="button button--sm" + > + Open + + <.action + phx-click={Corex.FloatingPanel.set_open("floating-panel-api-bind", false)} + class="button button--sm" + > + Close + +
+ + <.floating_panel_api_fixture + id="floating-panel-api-bind" + inner_text="Open and close via phx-click and Corex.FloatingPanel.set_open/2." + /> + """ + end + + def api_client_js_code do + ~S""" +
+ + +
+ + + + <.floating_panel id="floating-panel-api-js" class="floating-panel"> + <:open_trigger>Close panel + <:closed_trigger>Open panel + <:minimize_trigger> + <.heroicon name="hero-arrow-down-left" class="icon" /> + + <:maximize_trigger> + <.heroicon name="hero-arrows-pointing-out" class="icon" /> + + <:default_trigger> + <.heroicon name="hero-rectangle-stack" class="icon" /> + + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + <:content> +

Open and close by dispatching corex:floating-panel:set-open on the panel root.

+ + + """ + end + + def api_client_js_example(assigns) do + ~H""" +
+ + +
+ + + + <.floating_panel_api_fixture + id="floating-panel-api-js" + inner_text="Open and close by dispatching corex:floating-panel:set-open on the panel root." + /> + """ + end + + def api_server_heex_code do + """ +
+ <.action phx-click="floating_panel_api_server_open" class="button button--sm"> + Open + + <.action phx-click="floating_panel_api_server_close" class="button button--sm"> + Close + +
+ + #{fp_api_panel_snippet("floating-panel-api-server", "Open and close via LiveView push_event and Corex.FloatingPanel.set_open/3.")} + """ + end + + def api_server_handler_code do + ~S""" + def handle_event("floating_panel_api_server_open", _, socket) do + {:noreply, Corex.FloatingPanel.set_open(socket, "floating-panel-api-server", true)} + end + + def handle_event("floating_panel_api_server_close", _, socket) do + {:noreply, Corex.FloatingPanel.set_open(socket, "floating-panel-api-server", false)} + end + """ + end + + def api_server_example(assigns) do + ~H""" +
+ <.action phx-click="floating_panel_api_server_open" class="button button--sm"> + Open + + <.action phx-click="floating_panel_api_server_close" class="button button--sm"> + Close + +
+ + <.floating_panel_api_fixture + id="floating-panel-api-server" + inner_text="Open and close via LiveView push_event and Corex.FloatingPanel.set_open/3." + /> + """ + end + + def anatomy_basic_example(assigns) do + ~H""" + <.floating_panel id="floating-panel-anatomy" class="floating-panel"> + <:open_trigger>Close panel + <:closed_trigger>Open panel + <:minimize_trigger> + <.heroicon name="hero-arrow-down-left" class="icon" /> + + <:maximize_trigger> + <.heroicon name="hero-arrows-pointing-out" class="icon" /> + + <:default_trigger> + <.heroicon name="hero-rectangle-stack" class="icon" /> + + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + <:content> +

+ Congue molestie ipsum gravida a. Sed ac eros luctus, cursus turpis + non, pellentesque elit. Pellentesque sagittis fermentum. +

+ + + """ + end + + def events_server_heex do + ~S""" + <.floating_panel + id="fp-events-live" + class="floating-panel" + on_open_change="floating_panel_open_changed" + on_open_change_client="floating-panel-open-changed" + > + <:open_trigger>Close panel + <:closed_trigger>Open panel + <:minimize_trigger> + <.heroicon name="hero-arrow-down-left" class="icon" /> + + <:maximize_trigger> + <.heroicon name="hero-arrows-pointing-out" class="icon" /> + + <:default_trigger> + <.heroicon name="hero-rectangle-stack" class="icon" /> + + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + <:content> +

Lorem ipsum dolor sit amet.

+ + + """ + end + + attr :id, :string, required: true + attr :inner_text, :string, required: true + + def floating_panel_api_fixture(assigns) do + ~H""" + <.floating_panel id={@id} class="floating-panel"> + <:open_trigger>Close panel + <:closed_trigger>Open panel + <:minimize_trigger> + <.heroicon name="hero-arrow-down-left" class="icon" /> + + <:maximize_trigger> + <.heroicon name="hero-arrows-pointing-out" class="icon" /> + + <:default_trigger> + <.heroicon name="hero-rectangle-stack" class="icon" /> + + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + <:content> +

{@inner_text}

+ + + """ + end + + defp fp_api_panel_snippet(id, inner_text) do + """ + <.floating_panel id="#{id}" class="floating-panel"> + <:open_trigger>Close panel + <:closed_trigger>Open panel + <:minimize_trigger> + <.heroicon name="hero-arrow-down-left" class="icon" /> + + <:maximize_trigger> + <.heroicon name="hero-arrows-pointing-out" class="icon" /> + + <:default_trigger> + <.heroicon name="hero-rectangle-stack" class="icon" /> + + <:close_trigger> + <.heroicon name="hero-x-mark" class="icon" /> + + <:content> +

#{inner_text}

+ + + """ + end +end diff --git a/e2e/lib/e2e_web/demos/layout_heading_demo.ex b/e2e/lib/e2e_web/demos/layout_heading_demo.ex new file mode 100644 index 00000000..4e447ea0 --- /dev/null +++ b/e2e/lib/e2e_web/demos/layout_heading_demo.ex @@ -0,0 +1,101 @@ +defmodule E2eWeb.Demos.LayoutHeadingDemo do + use E2eWeb, :html + + def title_only_code do + ~S""" + <.layout_heading> + <:title>Page Title + + """ + end + + def title_only_example(assigns) do + ~H""" + <.layout_heading> + <:title>Page Title + + """ + end + + def title_and_subtitle_code do + ~S""" + <.layout_heading> + <:title>Page Title + <:subtitle>Controller View + + """ + end + + def title_and_subtitle_example(assigns) do + ~H""" + <.layout_heading> + <:title>Page Title + <:subtitle>Controller View + + """ + end + + def with_actions_code do + ~S""" + <.layout_heading> + <:title>Page Title + <:subtitle>Controller View + <:actions> + <.navigate to={~p"/"} type="href" class="button"> + <.heroicon name="hero-arrow-left" class="icon" /> Back + + + + """ + end + + def with_actions_example(assigns) do + ~H""" + <.layout_heading> + <:title>Page Title + <:subtitle>Controller View + <:actions> + <.navigate to={~p"/"} type="href" class="button"> + <.heroicon name="hero-arrow-left" class="icon" /> Back + + + + """ + end + + def styling_wrapper_code do + ~S""" + <.layout_heading class="layout-heading"> + <:title>Full width + <:subtitle>Default spacing from the layout-heading stylesheet. + + """ + end + + def styling_wrapper_example(assigns) do + ~H""" + <.layout_heading class="layout-heading"> + <:title>Full width + <:subtitle>Default spacing from the layout-heading stylesheet. + + """ + end + + def styling_constrained_code do + ~S""" + <.layout_heading class="layout-heading max-w-3xl mx-auto"> + <:title>Constrained width + <:subtitle>Combine utility classes on the same element as the component root. + + """ + end + + def styling_constrained_example(assigns) do + ~H""" + <.layout_heading class="layout-heading max-w-3xl mx-auto"> + <:title>Constrained width + <:subtitle>Combine utility classes on the same element as the component root. + + """ + end +end diff --git a/e2e/lib/e2e_web/demos/listbox_demo.ex b/e2e/lib/e2e_web/demos/listbox_demo.ex new file mode 100644 index 00000000..7d1d40b9 --- /dev/null +++ b/e2e/lib/e2e_web/demos/listbox_demo.ex @@ -0,0 +1,393 @@ +defmodule E2eWeb.Demos.ListboxDemo do + use E2eWeb, :html + + @pattern_snippets Path.join(__DIR__, "listbox_pattern_snippets") + + def items_minimal do + Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ]) + end + + def items_grouped do + Corex.List.new([ + %{label: "France", id: "fra", group: "Europe"}, + %{label: "Belgium", id: "bel", group: "Europe"}, + %{label: "Germany", id: "deu", group: "Europe"}, + %{label: "Japan", id: "jpn", group: "Asia"}, + %{label: "China", id: "chn", group: "Asia"}, + %{label: "USA", id: "usa", group: "North America"} + ]) + end + + def items_extended do + items_minimal() + end + + def items_extended_grouped do + Corex.List.new([ + %{label: "France", id: "fra", group: "Europe"}, + %{label: "Belgium", id: "bel", group: "Europe"}, + %{label: "Germany", id: "deu", group: "Europe"}, + %{label: "Japan", id: "jpn", group: "Asia"}, + %{label: "China", id: "chn", group: "Asia"}, + %{label: "South Korea", id: "kor", group: "Asia"} + ]) + end + + def anatomy_minimal_code do + ~S""" + <.listbox id="listbox-anatomy-minimal" class="listbox" items={items_minimal()}> + <:label>Choose a country + + """ + end + + def anatomy_minimal_example(assigns) do + assigns = assign(assigns, :items, items_minimal()) + + ~H""" + <.listbox id="listbox-anatomy-minimal" class="listbox" items={@items}> + <:label>Choose a country + + """ + end + + def anatomy_with_indicator_code do + ~S""" + <.listbox id="listbox-anatomy-indicator" class="listbox" items={items_minimal()}> + <:label>Choose a country + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def anatomy_with_indicator_example(assigns) do + assigns = assign(assigns, :items, items_minimal()) + + ~H""" + <.listbox id="listbox-anatomy-indicator" class="listbox" items={@items}> + <:label>Choose a country + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def anatomy_grouped_code do + ~S""" + <.listbox id="listbox-anatomy-grouped" class="listbox" items={items_grouped()}> + <:label>Choose a country + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def anatomy_grouped_example(assigns) do + assigns = assign(assigns, :items, items_grouped()) + + ~H""" + <.listbox id="listbox-anatomy-grouped" class="listbox" items={@items}> + <:label>Choose a country + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def anatomy_extended_code do + ~S""" + <.listbox id="listbox-anatomy-extended" class="listbox" items={items_extended()}> + <:label>Country of residence + <:item :let={%{item: entry}}> + + {entry.label} + + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def anatomy_extended_example(assigns) do + assigns = assign(assigns, :items, items_extended()) + + ~H""" + <.listbox id="listbox-anatomy-extended" class="listbox" items={@items}> + <:label>Country of residence + <:item :let={%{item: entry}}> + + {entry.label} + + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def anatomy_extended_grouped_code do + ~S""" + <.listbox + id="listbox-anatomy-extended-grouped" + class="listbox" + aria_label="Extended grouped countries" + items={items_extended_grouped()} + > + <:item :let={%{item: entry}}> + + {entry.label} + + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def anatomy_extended_grouped_example(assigns) do + assigns = assign(assigns, :items, items_extended_grouped()) + + ~H""" + <.listbox + id="listbox-anatomy-extended-grouped" + class="listbox" + aria_label="Extended grouped countries" + items={@items} + > + <:item :let={%{item: entry}}> + + {entry.label} + + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def patterns_stream_demo_heex do + ~S""" +
+
+ <.action phx-click="add_item" class="button button--sm button--accent"> + <.heroicon name="hero-plus" /> Add item + + <.action phx-click="reset" class="button button--sm button--alert"> + Reset + +
+ <.listbox id="stream-listbox" class="listbox" items={Corex.List.new(@items_list)}> + <:label>Choose an item + <:empty>No items + <:item_indicator><.heroicon name="hero-check" /> + +
+ """ + end + + def patterns_stream_my_app, do: read_pattern_snippet("patterns_stream_my_app.txt") + + def patterns_stream_grouped_demo_heex do + ~S""" +
+
+ <.action + phx-click="add_to_group" + phx-value-group="Europe" + class="button button--sm button--accent" + > + <.heroicon name="hero-plus" /> Add to Europe + + <.action + phx-click="add_to_group" + phx-value-group="Asia" + class="button button--sm button--accent" + > + <.heroicon name="hero-plus" /> Add to Asia + + <.action phx-click="reset_grouped" class="button button--sm button--alert"> + Reset + +
+ <.listbox + id="stream-grouped-listbox" + class="listbox" + items={Corex.List.new(@grouped_items_list)} + > + <:label>Choose a country + <:empty>No items + <:item_indicator><.heroicon name="hero-check" /> + +
+ """ + end + + def patterns_stream_grouped_my_app, + do: read_pattern_snippet("patterns_stream_grouped_my_app.txt") + + def patterns_controlled_heex do + ~S""" + <.listbox + id="listbox-patterns-controlled-field" + class="listbox" + items={ + Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ]) + } + selection_mode="multiple" + controlled + value={@listbox_controlled_value} + on_value_change="listbox_patterns_controlled_value" + > + <:label>Choose countries + <:item_indicator><.heroicon name="hero-check" /> + +

+ value: {inspect(@listbox_controlled_value)} +

+ """ + end + + def patterns_controlled_elixir do + patterns_controlled_my_app() + end + + def patterns_controlled_my_app, do: read_pattern_snippet("patterns_controlled_my_app.txt") + + defp read_pattern_snippet(name), do: File.read!(Path.join(@pattern_snippets, name)) + + def api_set_value_client_binding_code do + ~S""" + <.action phx-click={Corex.Listbox.set_value("listbox-api-sv-client", ["bel"])}>Belgium + <.action phx-click={Corex.Listbox.set_value("listbox-api-sv-client", [])}>Clear + <.listbox id="listbox-api-sv-client" class="listbox" items={items_minimal()}> + <:label>Choose a country + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def api_set_value_server_heex do + ~S""" + <.action phx-click="listbox_api_set_value">Belgium + <.listbox id="listbox-api-sv-server" class="listbox" items={items_minimal()}> + <:label>Choose a country + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def api_set_value_server_elixir do + ~S""" + def handle_event("listbox_api_set_value", _params, socket) do + {:noreply, Corex.Listbox.set_value(socket, "listbox-api-sv-server", ["bel"])} + end + """ + end + + def api_set_value_client_js do + ~S""" + const el = document.getElementById("listbox-api-sv-js"); + el?.dispatchEvent( + new CustomEvent("corex:listbox:set-value", { + bubbles: false, + detail: { value: ["deu"] }, + }) + ); + """ + end + + def api_value_client_binding_code do + ~S""" + <.action phx-click={Corex.Listbox.value("listbox-api-val-client")}>Read selection + <.listbox id="listbox-api-val-client" class="listbox" items={items_minimal()}> + <:label>Choose a country + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def api_value_server_heex do + ~S""" + <.action phx-click="listbox_api_value_server">Read selection + <.listbox id="listbox-api-val-server" class="listbox" items={items_minimal()}> + <:label>Choose a country + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def api_value_server_elixir do + ~S""" + def handle_event("listbox_api_value_server", _params, socket) do + {:noreply, Corex.Listbox.value(socket, "listbox-api-val-server")} + end + + def handle_event("listbox_value_response", %{"id" => id, "value" => value}, socket) do + # e.g. Corex.Toast.push_toast(...) + {:noreply, socket} + end + """ + end + + def events_server_heex do + ~S""" + <.listbox + id="listbox-events-server" + class="listbox" + items={items_minimal()} + on_value_change="listbox_value_changed" + > + <:label>Choose a country + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("listbox_value_changed", %{"id" => id, "value" => value}, socket) do + log = %{time: "12:00:00", source: "server", value: inspect(value)} + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + ~S""" + <.listbox + id="listbox-events-client" + class="listbox" + items={items_minimal()} + on_value_change_client="listbox-value-changed" + > + <:label>Choose a country + <:item_indicator><.heroicon name="hero-check" /> + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("listbox-events-client"); + el?.addEventListener("listbox-value-changed", (event) => { + const { id, value, items } = event.detail ?? {}; + console.log({ id, value, items }); + }); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById("listbox-events-client"); + type Detail = { id?: string; value?: string[]; items?: unknown[] }; + el?.addEventListener("listbox-value-changed", (event: Event) => { + const d = (event as CustomEvent).detail ?? {}; + console.log({ id: d.id, value: d.value, items: d.items }); + }); + """ + end +end diff --git a/e2e/lib/e2e_web/demos/listbox_pattern_snippets/patterns_controlled_my_app.txt b/e2e/lib/e2e_web/demos/listbox_pattern_snippets/patterns_controlled_my_app.txt new file mode 100644 index 00000000..5654bc06 --- /dev/null +++ b/e2e/lib/e2e_web/demos/listbox_pattern_snippets/patterns_controlled_my_app.txt @@ -0,0 +1,54 @@ +defmodule MyAppWeb.ListboxControlledDemoLive do + use MyAppWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :listbox_controlled_value, ["fra", "bel"])} + end + + @impl true + def handle_event("listbox_patterns_controlled_value", %{"value" => value}, socket) + when is_list(value) do + {:noreply, assign(socket, :listbox_controlled_value, value)} + end + + @impl true + def render(assigns) do + ~H""" + +
+ <.listbox + id="listbox-patterns-controlled-field" + class="listbox" + items={ + Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ]) + } + selection_mode="multiple" + controlled + value={@listbox_controlled_value} + on_value_change="listbox_patterns_controlled_value" + > + <:label>Choose countries + <:item_indicator><.heroicon name="hero-check" /> + +

+ value: {inspect(@listbox_controlled_value)} +

+
+
+ """ + end +end diff --git a/e2e/lib/e2e_web/demos/listbox_pattern_snippets/patterns_stream_grouped_my_app.txt b/e2e/lib/e2e_web/demos/listbox_pattern_snippets/patterns_stream_grouped_my_app.txt new file mode 100644 index 00000000..925eb3b8 --- /dev/null +++ b/e2e/lib/e2e_web/demos/listbox_pattern_snippets/patterns_stream_grouped_my_app.txt @@ -0,0 +1,93 @@ +defmodule MyAppWeb.ListboxStreamGroupedDemoLive do + use MyAppWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + initial = [ + %{id: "g1", label: "France", group: "Europe"}, + %{id: "g2", label: "Japan", group: "Asia"}, + %{id: "g3", label: "Germany", group: "Europe"} + ] + + socket = + socket + |> stream_configure(:grouped_items, dom_id: &("listbox:stream-grouped-listbox:item:" <> to_string(&1.id))) + |> stream(:grouped_items, initial) + |> assign(:grouped_items_list, initial) + |> assign(:next_grouped_id, 4) + + {:ok, socket} + end + + @impl true + def handle_event("add_to_group", %{"group" => group}, socket) do + n = socket.assigns.next_grouped_id + id = "g" <> Integer.to_string(n) + item = %{id: id, label: "Item " <> Integer.to_string(n), group: group} + + {:noreply, + socket + |> stream_insert(:grouped_items, item) + |> assign(:grouped_items_list, socket.assigns.grouped_items_list ++ [item]) + |> assign(:next_grouped_id, n + 1)} + end + + @impl true + def handle_event("reset_grouped", _params, socket) do + initial = [ + %{id: "g1", label: "France", group: "Europe"}, + %{id: "g2", label: "Japan", group: "Asia"}, + %{id: "g3", label: "Germany", group: "Europe"} + ] + + {:noreply, + socket + |> stream(:grouped_items, initial, reset: true) + |> assign(:grouped_items_list, initial) + |> assign(:next_grouped_id, 4)} + end + + @impl true + def render(assigns) do + ~H""" + +
+
+ <.action + phx-click="add_to_group" + phx-value-group="Europe" + class="button button--sm button--accent" + > + <.heroicon name="hero-plus" /> Add to Europe + + <.action + phx-click="add_to_group" + phx-value-group="Asia" + class="button button--sm button--accent" + > + <.heroicon name="hero-plus" /> Add to Asia + + <.action phx-click="reset_grouped" class="button button--sm button--alert"> + Reset + +
+ <.listbox + id="stream-grouped-listbox" + class="listbox" + items={Corex.List.new(@grouped_items_list)} + > + <:label>Choose a country + <:empty>No items + <:item_indicator><.heroicon name="hero-check" /> + +
+
+ """ + end +end diff --git a/e2e/lib/e2e_web/demos/listbox_pattern_snippets/patterns_stream_my_app.txt b/e2e/lib/e2e_web/demos/listbox_pattern_snippets/patterns_stream_my_app.txt new file mode 100644 index 00000000..fd32316e --- /dev/null +++ b/e2e/lib/e2e_web/demos/listbox_pattern_snippets/patterns_stream_my_app.txt @@ -0,0 +1,101 @@ +defmodule MyAppWeb.ListboxStreamDemoLive do + use MyAppWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + initial = [ + %{id: "1", label: "Apple"}, + %{id: "2", label: "Banana"}, + %{id: "3", label: "Cherry"} + ] + + socket = + socket + |> stream_configure(:items, dom_id: &("listbox:stream-listbox:item:" <> to_string(&1.id))) + |> stream(:items, initial) + |> assign(:items_list, initial) + |> assign(:next_id, 4) + + if connected?(socket) do + Process.send_after(self(), :add_timestamp_item, 3_000) + end + + {:ok, socket} + end + + @impl true + def handle_info(:add_timestamp_item, socket) do + Process.send_after(self(), :add_timestamp_item, 10_000) + id = to_string(socket.assigns.next_id) + + time = + DateTime.utc_now() + |> DateTime.truncate(:second) + |> DateTime.to_time() + |> Time.to_string() + + item = %{id: id, label: "Item " <> id <> " @ " <> time} + + {:noreply, + socket + |> stream_insert(:items, item) + |> assign(:items_list, socket.assigns.items_list ++ [item]) + |> assign(:next_id, socket.assigns.next_id + 1)} + end + + @impl true + def handle_event("add_item", _params, socket) do + id = to_string(socket.assigns.next_id) + item = %{id: id, label: "Item " <> id} + + {:noreply, + socket + |> stream_insert(:items, item) + |> assign(:items_list, socket.assigns.items_list ++ [item]) + |> assign(:next_id, socket.assigns.next_id + 1)} + end + + @impl true + def handle_event("reset", _params, socket) do + initial = [ + %{id: "1", label: "Apple"}, + %{id: "2", label: "Banana"}, + %{id: "3", label: "Cherry"} + ] + + {:noreply, + socket + |> stream(:items, initial, reset: true) + |> assign(:items_list, initial) + |> assign(:next_id, 4)} + end + + @impl true + def render(assigns) do + ~H""" + +
+
+ <.action phx-click="add_item" class="button button--sm button--accent"> + <.heroicon name="hero-plus" /> Add item + + <.action phx-click="reset" class="button button--sm button--alert"> + Reset + +
+ <.listbox id="stream-listbox" class="listbox" items={Corex.List.new(@items_list)}> + <:label>Choose an item + <:empty>No items + <:item_indicator><.heroicon name="hero-check" /> + +
+
+ """ + end +end diff --git a/e2e/lib/e2e_web/demos/marquee_demo.ex b/e2e/lib/e2e_web/demos/marquee_demo.ex new file mode 100644 index 00000000..5ebf68a0 --- /dev/null +++ b/e2e/lib/e2e_web/demos/marquee_demo.ex @@ -0,0 +1,628 @@ +defmodule E2eWeb.Demos.MarqueeDemo do + use E2eWeb, :html + + def api_demo_items do + [ + %{name: "Apple", logo: "🍎"}, + %{name: "Banana", logo: "🍌"}, + %{name: "Cherry", logo: "🍒"}, + %{name: "Grape", logo: "🍇"}, + %{name: "Lemon", logo: "🍋"} + ] + end + + def anatomy_minimal_code do + ~S""" + <.marquee + id="marquee-anatomy-minimal" + class="marquee" + items={[ + %{name: "Apple", logo: "🍎"}, + %{name: "Banana", logo: "🍌"}, + %{name: "Cherry", logo: "🍒"} + ]} + duration={20} + spacing="2rem" + pause_on_interaction + > + <:item :let={item}> + {item.logo} + {item.name} + + + """ + end + + def anatomy_minimal_example(assigns) do + ~H""" + <.marquee + id="marquee-anatomy-minimal" + class="marquee" + items={[ + %{name: "Apple", logo: "🍎"}, + %{name: "Banana", logo: "🍌"}, + %{name: "Cherry", logo: "🍒"} + ]} + duration={20} + spacing="2rem" + pause_on_interaction + > + <:item :let={item}> + {item.logo} + {item.name} + + + """ + end + + def anatomy_custom_slots_code do + ~S""" + <.marquee + id="marquee-anatomy-custom-slots" + class="marquee" + items={[ + %{name: "Home", icon: "hero-home"}, + %{name: "User", icon: "hero-user"}, + %{name: "Cog", icon: "hero-cog-6-tooth"}, + %{name: "Heart", icon: "hero-heart"}, + %{name: "Star", icon: "hero-star"} + ]} + duration={25} + spacing="2rem" + pause_on_interaction + > + <:item :let={item}> + <.heroicon name={item.icon} /> + {item.name} + + + """ + end + + def anatomy_custom_slots_example(assigns) do + ~H""" + <.marquee + id="marquee-anatomy-custom-slots" + class="marquee" + items={[ + %{name: "Home", icon: "hero-home"}, + %{name: "User", icon: "hero-user"}, + %{name: "Cog", icon: "hero-cog-6-tooth"}, + %{name: "Heart", icon: "hero-heart"}, + %{name: "Star", icon: "hero-star"} + ]} + duration={25} + spacing="2rem" + pause_on_interaction + > + <:item :let={item}> + <.heroicon name={item.icon} /> + {item.name} + + + """ + end + + def anatomy_with_images_code do + ~S""" + <.marquee + id="marquee-anatomy-with-images" + class="marquee" + items={[ + %{name: "Elixir", img: "/images/tech/elixir.svg"}, + %{name: "Phoenix", img: "/images/tech/phoenix.svg"}, + %{name: "TypeScript", img: "/images/tech/typescript.svg"} + ]} + speed={50} + spacing="3rem" + pause_on_interaction + > + <:item :let={item}> + {item.name} + + + """ + end + + def anatomy_with_images_example(assigns) do + ~H""" + <.marquee + id="marquee-anatomy-with-images" + class="marquee" + items={[ + %{name: "Elixir", img: "/images/tech/elixir.svg"}, + %{name: "Phoenix", img: "/images/tech/phoenix.svg"}, + %{name: "TypeScript", img: "/images/tech/typescript.svg"} + ]} + speed={50} + spacing="3rem" + pause_on_interaction + > + <:item :let={item}> + {item.name} + + + """ + end + + def api_pause_client_binding_code do + """ +
+ <.action phx-click={Corex.Marquee.pause("api-pause-client")} class="button button--sm"> + Pause + +
+ + #{api_marquee_snippet("api-pause-client")} + """ + end + + def api_pause_client_binding_example(assigns) do + ~H""" +
+ <.action phx-click={Corex.Marquee.pause("api-pause-client")} class="button button--sm"> + Pause + +
+ + <.marquee_api_fixture id="api-pause-client" items={api_demo_items()} /> + """ + end + + def api_pause_client_js_heex do + """ +
+ +
+ + + + #{api_marquee_snippet("api-pause-js")} + """ + end + + def api_pause_client_js_js do + """ + const el = document.getElementById("api-pause-js"); + el?.dispatchEvent(new CustomEvent("corex:marquee:pause", { bubbles: false })); + """ + end + + def api_pause_client_js_ts do + """ + const el = document.getElementById("api-pause-js"); + el?.dispatchEvent(new CustomEvent("corex:marquee:pause", { bubbles: false })); + """ + end + + def api_pause_client_js_example(assigns) do + ~H""" +
+ +
+ + + + <.marquee_api_fixture id="api-pause-js" items={api_demo_items()} /> + """ + end + + def api_pause_server_heex do + """ +
+ <.action phx-click="marquee_api_server_pause" class="button button--sm">Pause +
+ + #{api_marquee_snippet("api-pause-server")} + """ + end + + def api_pause_server_elixir do + ~S""" + def handle_event("marquee_api_server_pause", _, socket) do + {:noreply, Corex.Marquee.pause(socket, "api-pause-server")} + end + """ + end + + def api_pause_server_example(assigns) do + ~H""" +
+ <.action phx-click="marquee_api_server_pause" class="button button--sm">Pause +
+ + <.marquee_api_fixture id="api-pause-server" items={api_demo_items()} /> + """ + end + + def api_resume_client_binding_code do + """ +
+ <.action phx-click={Corex.Marquee.resume("api-resume-client")} class="button button--sm"> + Resume + +
+ + #{api_marquee_snippet("api-resume-client")} + """ + end + + def api_resume_client_binding_example(assigns) do + ~H""" +
+ <.action phx-click={Corex.Marquee.resume("api-resume-client")} class="button button--sm"> + Resume + +
+ + <.marquee_api_fixture id="api-resume-client" items={api_demo_items()} /> + """ + end + + def api_resume_client_js_heex do + """ +
+ +
+ + + + #{api_marquee_snippet("api-resume-js")} + """ + end + + def api_resume_client_js_js do + """ + const el = document.getElementById("api-resume-js"); + el?.dispatchEvent(new CustomEvent("corex:marquee:resume", { bubbles: false })); + """ + end + + def api_resume_client_js_ts do + """ + const el = document.getElementById("api-resume-js"); + el?.dispatchEvent(new CustomEvent("corex:marquee:resume", { bubbles: false })); + """ + end + + def api_resume_client_js_example(assigns) do + ~H""" +
+ +
+ + + + <.marquee_api_fixture id="api-resume-js" items={api_demo_items()} /> + """ + end + + def api_resume_server_heex do + """ +
+ <.action phx-click="marquee_api_server_resume" class="button button--sm">Resume +
+ + #{api_marquee_snippet("api-resume-server")} + """ + end + + def api_resume_server_elixir do + ~S""" + def handle_event("marquee_api_server_resume", _, socket) do + {:noreply, Corex.Marquee.resume(socket, "api-resume-server")} + end + """ + end + + def api_resume_server_example(assigns) do + ~H""" +
+ <.action phx-click="marquee_api_server_resume" class="button button--sm">Resume +
+ + <.marquee_api_fixture id="api-resume-server" items={api_demo_items()} /> + """ + end + + def api_toggle_client_binding_code do + """ +
+ <.action phx-click={Corex.Marquee.toggle_pause("api-toggle-client")} class="button button--sm"> + Toggle pause + +
+ + #{api_marquee_snippet("api-toggle-client")} + """ + end + + def api_toggle_client_binding_example(assigns) do + ~H""" +
+ <.action phx-click={Corex.Marquee.toggle_pause("api-toggle-client")} class="button button--sm"> + Toggle pause + +
+ + <.marquee_api_fixture id="api-toggle-client" items={api_demo_items()} /> + """ + end + + def api_toggle_client_js_heex do + """ +
+ +
+ + + + #{api_marquee_snippet("api-toggle-js")} + """ + end + + def api_toggle_client_js_js do + """ + const el = document.getElementById("api-toggle-js"); + el?.dispatchEvent(new CustomEvent("corex:marquee:toggle-pause", { bubbles: false })); + """ + end + + def api_toggle_client_js_ts do + """ + const el = document.getElementById("api-toggle-js"); + el?.dispatchEvent(new CustomEvent("corex:marquee:toggle-pause", { bubbles: false })); + """ + end + + def api_toggle_client_js_example(assigns) do + ~H""" +
+ +
+ + + + <.marquee_api_fixture id="api-toggle-js" items={api_demo_items()} /> + """ + end + + def api_toggle_server_heex do + """ +
+ <.action phx-click="marquee_api_server_toggle_pause" class="button button--sm"> + Toggle pause + +
+ + #{api_marquee_snippet("api-toggle-server")} + """ + end + + def api_toggle_server_elixir do + ~S""" + def handle_event("marquee_api_server_toggle_pause", _, socket) do + {:noreply, Corex.Marquee.toggle_pause(socket, "api-toggle-server")} + end + """ + end + + def api_toggle_server_example(assigns) do + ~H""" +
+ <.action phx-click="marquee_api_server_toggle_pause" class="button button--sm"> + Toggle pause + +
+ + <.marquee_api_fixture id="api-toggle-server" items={api_demo_items()} /> + """ + end + + def events_server_heex do + ~S""" + <.marquee + id="marquee-events-server" + class="marquee" + on_pause_change="pause_changed" + on_loop_complete="loop_complete" + on_complete="complete" + loop_count={3} + items={[ + %{name: "Apple", logo: "🍎"}, + %{name: "Banana", logo: "🍌"}, + %{name: "Cherry", logo: "🍒"}, + %{name: "Grape", logo: "🍇"}, + %{name: "Lemon", logo: "🍋"} + ]} + duration={12} + spacing="2rem" + pause_on_interaction + > + <:item :let={item}> + {item.logo} + {item.name} + + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("pause_changed", %{"paused" => paused, "id" => id}, socket) do + log = new_log("server", id, inspect(%{kind: "pause_changed", paused: paused})) + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + + def handle_event("loop_complete", %{"id" => id}, socket) do + log = new_log("server", id, inspect(%{kind: "loop_complete"})) + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + + def handle_event("complete", %{"id" => id}, socket) do + log = new_log("server", id, inspect(%{kind: "complete"})) + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + ~S""" + <.marquee + id="marquee-events-client" + class="marquee" + on_pause_change_client="marquee-pause-changed-client" + on_loop_complete_client="marquee-loop-complete-client" + on_complete_client="marquee-complete-client" + loop_count={3} + items={[ + %{name: "Apple", logo: "🍎"}, + %{name: "Banana", logo: "🍌"}, + %{name: "Cherry", logo: "🍒"}, + %{name: "Grape", logo: "🍇"}, + %{name: "Lemon", logo: "🍋"} + ]} + duration={12} + spacing="2rem" + pause_on_interaction + > + <:item :let={item}> + {item.logo} + {item.name} + + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("marquee-events-client"); + el?.addEventListener("marquee-pause-changed-client", (e) => { + console.log("pause", e.detail.id, e.detail.paused); + }); + el?.addEventListener("marquee-loop-complete-client", (e) => { + console.log("loop", e.detail.id); + }); + el?.addEventListener("marquee-complete-client", (e) => { + console.log("complete", e.detail.id); + }); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById("marquee-events-client"); + el?.addEventListener("marquee-pause-changed-client", (e: Event) => { + const d = (e as CustomEvent<{ id: string; paused: boolean }>).detail; + console.log("pause", d.id, d.paused); + }); + el?.addEventListener("marquee-loop-complete-client", (e: Event) => { + const d = (e as CustomEvent<{ id: string }>).detail; + console.log("loop", d.id); + }); + el?.addEventListener("marquee-complete-client", (e: Event) => { + const d = (e as CustomEvent<{ id: string }>).detail; + console.log("complete", d.id); + }); + """ + end + + attr :id, :string, required: true + attr :items, :list, required: true + + def marquee_api_fixture(assigns) do + ~H""" + <.marquee + id={@id} + class="marquee" + items={@items} + duration={20} + spacing="2rem" + pause_on_interaction + > + <:item :let={item}> + {item.logo} + {item.name} + + + """ + end + + defp api_marquee_snippet(id) do + """ + <.marquee + id="#{id}" + class="marquee" + items={[ + %{name: "Apple", logo: "🍎"}, + %{name: "Banana", logo: "🍌"}, + %{name: "Cherry", logo: "🍒"}, + %{name: "Grape", logo: "🍇"}, + %{name: "Lemon", logo: "🍋"} + ]} + duration={20} + spacing="2rem" + pause_on_interaction + > + <:item :let={item}> + {item.logo} + {item.name} + + + """ + end +end diff --git a/e2e/lib/e2e_web/demos/menu_demo.ex b/e2e/lib/e2e_web/demos/menu_demo.ex new file mode 100644 index 00000000..612d6d9a --- /dev/null +++ b/e2e/lib/e2e_web/demos/menu_demo.ex @@ -0,0 +1,619 @@ +defmodule E2eWeb.Demos.MenuDemo do + use E2eWeb, :html + + def demo_leaf_items do + [ + %Corex.Tree.Item{id: "menu", label: "Menu"}, + %Corex.Tree.Item{id: "combobox", label: "Combobox"}, + %Corex.Tree.Item{id: "select", label: "Select"} + ] + end + + def anatomy_shared_leaf_items, do: demo_leaf_items() + + def anatomy_minimal_code do + ~S""" + <.menu + id="menu-anatomy-minimal" + class="menu" + items={[ + %Corex.Tree.Item{id: "menu", label: "Menu"}, + %Corex.Tree.Item{id: "combobox", label: "Combobox"}, + %Corex.Tree.Item{id: "select", label: "Select"} + ]} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def anatomy_minimal_example(assigns) do + ~H""" + <.menu + id="menu-anatomy-minimal" + class="menu" + items={demo_leaf_items()} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def basic_code, do: anatomy_minimal_code() + def basic_example(assigns), do: anatomy_minimal_example(assigns) + + def demo_grouped_items do + [ + %Corex.Tree.Item{id: "combobox", label: "Combobox", group: "Pickers"}, + %Corex.Tree.Item{id: "listbox", label: "Listbox", group: "Pickers"}, + %Corex.Tree.Item{id: "menu", label: "Menu", group: "Overlays"}, + %Corex.Tree.Item{id: "dialog", label: "Dialog", group: "Overlays"} + ] + end + + def anatomy_grouped_code do + ~S""" + <.menu + id="menu-anatomy-grouped" + class="menu" + items={[ + %Corex.Tree.Item{id: "combobox", label: "Combobox", group: "Pickers"}, + %Corex.Tree.Item{id: "listbox", label: "Listbox", group: "Pickers"}, + %Corex.Tree.Item{id: "menu", label: "Menu", group: "Overlays"}, + %Corex.Tree.Item{id: "dialog", label: "Dialog", group: "Overlays"} + ]} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def anatomy_grouped_example(assigns) do + ~H""" + <.menu + id="menu-anatomy-grouped" + class="menu" + items={demo_grouped_items()} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def grouped_code, do: anatomy_grouped_code() + def grouped_example(assigns), do: anatomy_grouped_example(assigns) + + def anatomy_nested_code do + ~S""" + <.menu + id="menu-anatomy-nested" + class="menu" + items={[ + %Corex.Tree.Item{id: "listbox", label: "Listbox"}, + %Corex.Tree.Item{ + id: "corex", + label: "Corex", + children: [ + %Corex.Tree.Item{ + id: "corex-menu", + label: "Menu", + children: [ + %Corex.Tree.Item{id: "menu", label: "Menu"}, + %Corex.Tree.Item{id: "combobox", label: "Combobox"}, + %Corex.Tree.Item{id: "select", label: "Select"} + ] + } + ] + }, + %Corex.Tree.Item{id: "tabs", label: "Tabs"} + ]} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + <:nested_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def anatomy_nested_example(assigns) do + ~H""" + <.menu + id="menu-anatomy-nested" + class="menu" + items={[ + %Corex.Tree.Item{id: "listbox", label: "Listbox"}, + %Corex.Tree.Item{ + id: "corex", + label: "Corex", + children: [ + %Corex.Tree.Item{ + id: "corex-menu", + label: "Menu", + children: demo_leaf_items() + } + ] + }, + %Corex.Tree.Item{id: "tabs", label: "Tabs"} + ]} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + <:nested_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def nested_code, do: anatomy_nested_code() + def nested_example(assigns), do: anatomy_nested_example(assigns) + + def demo_nested_grouped_children do + [ + %Corex.Tree.Item{id: "combobox", label: "Combobox", group: "Pickers"}, + %Corex.Tree.Item{id: "date-picker", label: "Date picker", group: "Pickers"}, + %Corex.Tree.Item{id: "menu", label: "Menu", group: "Overlays"}, + %Corex.Tree.Item{id: "dialog", label: "Dialog", group: "Overlays"} + ] + end + + def anatomy_nested_grouped_code do + ~S""" + <.menu + id="menu-anatomy-nested-grouped" + class="menu" + items={[ + %Corex.Tree.Item{id: "tabs", label: "Tabs"}, + %Corex.Tree.Item{ + id: "corex", + label: "Corex", + children: [ + %Corex.Tree.Item{id: "combobox", label: "Combobox", group: "Pickers"}, + %Corex.Tree.Item{id: "date-picker", label: "Date picker", group: "Pickers"}, + %Corex.Tree.Item{id: "menu", label: "Menu", group: "Overlays"}, + %Corex.Tree.Item{id: "dialog", label: "Dialog", group: "Overlays"} + ] + } + ]} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + <:nested_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def anatomy_nested_grouped_example(assigns) do + ~H""" + <.menu + id="menu-anatomy-nested-grouped" + class="menu" + items={[ + %Corex.Tree.Item{id: "tabs", label: "Tabs"}, + %Corex.Tree.Item{ + id: "corex", + label: "Corex", + children: demo_nested_grouped_children() + } + ]} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + <:nested_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_client_binding_code do + ~S""" +
+ <.action phx-click={Corex.Menu.set_open("menu-api", true)} class="button button--sm">Open + <.action phx-click={Corex.Menu.set_open("menu-api", false)} class="button button--sm">Close +
+ + <.menu + id="menu-api" + class="menu" + items={[ + %Corex.Tree.Item{id: "menu", label: "Menu"}, + %Corex.Tree.Item{id: "combobox", label: "Combobox"}, + %Corex.Tree.Item{id: "select", label: "Select"} + ]} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def api_client_binding_example(assigns) do + ~H""" +
+ <.action phx-click={Corex.Menu.set_open("menu-api", true)} class="button button--sm"> + Open + + <.action phx-click={Corex.Menu.set_open("menu-api", false)} class="button button--sm"> + Close + +
+ + <.menu + id="menu-api" + class="menu" + items={demo_leaf_items()} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def api_client_js_heex do + ~S""" +
+ + +
+ + <.menu + id="menu-api-js" + class="menu" + items={[ + %Corex.Tree.Item{id: "menu", label: "Menu"}, + %Corex.Tree.Item{id: "combobox", label: "Combobox"}, + %Corex.Tree.Item{id: "select", label: "Select"} + ]} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def api_client_js_js do + ~S""" + const root = document.getElementById("menu:menu-api-js"); + document.querySelector("[data-menu-api-open]")?.addEventListener("click", () => { + root?.dispatchEvent(new CustomEvent("corex:menu:set-open", { bubbles: false, detail: { open: true } })); + }); + document.querySelector("[data-menu-api-close]")?.addEventListener("click", () => { + root?.dispatchEvent(new CustomEvent("corex:menu:set-open", { bubbles: false, detail: { open: false } })); + }); + """ + end + + def api_client_js_ts do + ~S""" + const root = document.getElementById("menu:menu-api-js"); + document.querySelector("[data-menu-api-open]")?.addEventListener("click", () => { + root?.dispatchEvent( + new CustomEvent("corex:menu:set-open", { bubbles: false, detail: { open: true } }) + ); + }); + document.querySelector("[data-menu-api-close]")?.addEventListener("click", () => { + root?.dispatchEvent( + new CustomEvent("corex:menu:set-open", { bubbles: false, detail: { open: false } }) + ); + }); + """ + end + + def api_client_js_example(assigns) do + ~H""" + + """ + end + + def api_server_heex do + ~S""" +
+ <.action phx-click="menu_api_server_open" class="button button--sm">Open + <.action phx-click="menu_api_server_close" class="button button--sm">Close +
+ + <.menu + id="menu-api-server" + class="menu" + items={[ + %Corex.Tree.Item{id: "menu", label: "Menu"}, + %Corex.Tree.Item{id: "combobox", label: "Combobox"}, + %Corex.Tree.Item{id: "select", label: "Select"} + ]} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def api_server_elixir do + ~S""" + def handle_event("menu_api_server_open", _, socket) do + {:noreply, Corex.Menu.set_open(socket, "menu-api-server", true)} + end + + def handle_event("menu_api_server_close", _, socket) do + {:noreply, Corex.Menu.set_open(socket, "menu-api-server", false)} + end + """ + end + + def api_server_example(assigns) do + ~H""" +
+ <.action phx-click="menu_api_server_open" class="button button--sm">Open + <.action phx-click="menu_api_server_close" class="button button--sm">Close +
+ + <.menu + id="menu-api-server" + class="menu" + items={demo_leaf_items()} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def events_binding_code do + ~S""" + <.menu + id="menu-events-bind" + class="menu" + on_select="menu_bind_selected" + on_open_change="menu_bind_open" + items={[ + %Corex.Tree.Item{id: "menu", label: "Menu"}, + %Corex.Tree.Item{id: "combobox", label: "Combobox"}, + %Corex.Tree.Item{id: "select", label: "Select"} + ]} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def events_binding_example(assigns) do + ~H""" + <.menu + id="menu-events-bind" + class="menu" + on_select="menu_bind_selected" + on_open_change="menu_bind_open" + items={demo_leaf_items()} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def events_binding_elixir do + ~S""" + def handle_event("menu_bind_open", %{"open" => open, "id" => id}, socket) do + log = %{time: "12:00:00", source: "binding", value: inspect(%{open: open, id: id})} + {:noreply, stream_insert(socket, :bind_logs, log, at: 0)} + end + + def handle_event("menu_bind_selected", %{"value" => value, "id" => id}, socket) do + log = %{time: "12:00:00", source: "binding", value: inspect(%{value: value, id: id})} + {:noreply, stream_insert(socket, :bind_logs, log, at: 0)} + end + """ + end + + def events_server_heex do + ~S""" + <.menu + id="menu-events-server" + class="menu" + on_select="menu_selected" + on_open_change="menu_open_changed" + items={[ + %Corex.Tree.Item{id: "menu", label: "Menu"}, + %Corex.Tree.Item{id: "combobox", label: "Combobox"}, + %Corex.Tree.Item{id: "select", label: "Select"} + ]} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("menu_open_changed", %{"open" => open, "id" => id}, socket) do + log = %{time: "12:00:00", source: "server", value: inspect(%{open: open, id: id})} + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + + def handle_event("menu_selected", %{"value" => value, "id" => id}, socket) do + log = %{time: "12:00:00", source: "server", value: inspect(%{value: value, id: id})} + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + ~S""" + <.menu + id="menu-events-client" + class="menu" + on_select_client="menu-item-selected" + on_open_change_client="menu-open-changed" + items={[ + %Corex.Tree.Item{id: "menu", label: "Menu"}, + %Corex.Tree.Item{id: "combobox", label: "Combobox"}, + %Corex.Tree.Item{id: "select", label: "Select"} + ]} + > + <:trigger>Corex + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("menu:menu-events-client"); + el?.addEventListener("menu-open-changed", (e) => console.log(e.detail)); + el?.addEventListener("menu-item-selected", (e) => console.log(e.detail)); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById("menu:menu-events-client"); + el?.addEventListener("menu-open-changed", (e: Event) => + console.log((e as CustomEvent).detail) + ); + el?.addEventListener("menu-item-selected", (e: Event) => + console.log((e as CustomEvent).detail) + ); + """ + end + + def patterns_redirect_code do + """ + <.menu id="menu-pattern-redirect" class="menu" redirect items={[ + %Corex.Tree.Item{id: ~p"/menu/anatomy", label: "Anatomy"}, + %Corex.Tree.Item{id: ~p"/menu/api", label: "API"} + ]}> + <:trigger>Navigate + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def patterns_redirect_items do + [ + %Corex.Tree.Item{id: ~p"/menu/anatomy", label: "Anatomy"}, + %Corex.Tree.Item{id: ~p"/menu/api", label: "API"} + ] + end + + def patterns_redirect_example(assigns) do + ~H""" + <.menu id="menu-pattern-redirect" class="menu" redirect items={patterns_redirect_items()}> + <:trigger>Navigate + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def patterns_redirect_external_code do + ~S""" + <.menu + id="menu-pattern-external" + class="menu" + redirect + items={[ + %Corex.Tree.Item{id: "https://zagjs.com/components/react/menu", label: "Zag menu", new_tab: true}, + %Corex.Tree.Item{id: "https://hexdocs.pm/phoenix_live_view/", label: "Phoenix LiveView", new_tab: true} + ]} + > + <:trigger>External + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def patterns_redirect_external_example(assigns) do + ~H""" + <.menu + id="menu-pattern-external" + class="menu" + redirect + items={[ + %Corex.Tree.Item{ + id: "https://zagjs.com/components/react/menu", + label: "Zag menu", + new_tab: true + }, + %Corex.Tree.Item{ + id: "https://hexdocs.pm/phoenix_live_view/", + label: "Phoenix LiveView", + new_tab: true + } + ]} + > + <:trigger>External + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def patterns_redirect_types_code do + """ + <.menu + id="menu-pattern-types" + class="menu" + redirect + items={[ + %Corex.Tree.Item{id: ~p"/menu/playground", label: "href (default)", redirect: :href}, + %Corex.Tree.Item{id: ~p"/menu/events", label: "LiveView navigate", redirect: :navigate} + ]} + > + <:trigger>Redirect kinds + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end + + def patterns_redirect_types_items do + [ + %Corex.Tree.Item{id: ~p"/menu/playground", label: "href (default)", redirect: :href}, + %Corex.Tree.Item{id: ~p"/menu/events", label: "LiveView navigate", redirect: :navigate} + ] + end + + def patterns_redirect_types_example(assigns) do + ~H""" + <.menu + id="menu-pattern-types" + class="menu" + redirect + items={patterns_redirect_types_items()} + > + <:trigger>Redirect kinds + <:indicator><.heroicon name="hero-chevron-down" /> + + """ + end +end diff --git a/e2e/lib/e2e_web/demos/native_input_demo.ex b/e2e/lib/e2e_web/demos/native_input_demo.ex new file mode 100644 index 00000000..bd3fc610 --- /dev/null +++ b/e2e/lib/e2e_web/demos/native_input_demo.ex @@ -0,0 +1,1860 @@ +defmodule E2eWeb.Demos.NativeInputDemo do + use E2eWeb, :html + + def tag_options do + [ + "Elixir": "elixir", + Phoenix: "phoenix", + LiveView: "liveview", + Ecto: "ecto", + OTP: "otp" + ] + end + + def playground_code do + ~S""" + <.native_input type="text" id="native-input-play" name="demo[name]" class="native-input" disabled={false}> + <:label>Text + + """ + end + + def playground_example(assigns) do + assigns = assign_new(assigns, :disabled, fn -> false end) + + ~H""" + <.native_input + type="text" + id="native-input-play" + name="demo[name]" + class="native-input" + disabled={@disabled} + > + <:label>Text + + """ + end + + def anatomy_text_code do + ~S""" +
+ <.native_input type="text" id="text-with-icon" name="user[name]" class="native-input"> + <:label>Text + <:icon><.heroicon name="hero-pencil-square" class="icon" /> + + <.native_input type="text" id="text-basic" name="user[name]" class="native-input"> + <:label>Text + + <.native_input type="textarea" id="textarea" name="user[bio]" class="native-input"> + <:label>Bio + + <.native_input type="email" id="email-with-icon" name="user[email]" class="native-input"> + <:label>Email + <:icon><.heroicon name="hero-envelope" class="icon" /> + + <.native_input type="email" id="email-basic" name="user[email]" class="native-input"> + <:label>Email + + <.native_input type="url" id="url-with-icon" name="user[website]" class="native-input"> + <:label>Website + <:icon><.heroicon name="hero-link" class="icon" /> + + <.native_input type="url" id="url-basic" name="user[website]" class="native-input"> + <:label>Website + + <.native_input type="tel" id="tel-with-icon" name="user[phone]" class="native-input"> + <:label>Phone + <:icon><.heroicon name="hero-phone" class="icon" /> + + <.native_input type="tel" id="tel-basic" name="user[phone]" class="native-input"> + <:label>Phone + + <.native_input type="search" id="search-with-icon" name="q" class="native-input" placeholder="Search"> + <:label>Search + <:icon><.heroicon name="hero-magnifying-glass" class="icon" /> + + <.native_input type="search" id="search-basic" name="q" class="native-input" placeholder="Search"> + <:label>Search + + <.native_input type="password" id="password-with-icon" name="user[password]" class="native-input"> + <:label>Password + <:icon><.heroicon name="hero-lock-closed" class="icon" /> + + <.native_input type="password" id="password-basic" name="user[password]" class="native-input"> + <:label>Password + + <.native_input + type="number" + id="number" + name="user[count]" + value="42" + min="0" + max="100" + step="1" + class="native-input" + > + <:label>Number + +
+ """ + end + + def anatomy_text_example(assigns) do + _ = assigns + + ~H""" +
+ <.native_input type="text" id="text-with-icon" name="user[name]" class="native-input"> + <:label>Text + <:icon><.heroicon name="hero-pencil-square" class="icon" /> + + <.native_input type="text" id="text-basic" name="user[name]" class="native-input"> + <:label>Text + + <.native_input type="textarea" id="textarea" name="user[bio]" class="native-input"> + <:label>Bio + + <.native_input type="email" id="email-with-icon" name="user[email]" class="native-input"> + <:label>Email + <:icon><.heroicon name="hero-envelope" class="icon" /> + + <.native_input type="email" id="email-basic" name="user[email]" class="native-input"> + <:label>Email + + <.native_input type="url" id="url-with-icon" name="user[website]" class="native-input"> + <:label>Website + <:icon><.heroicon name="hero-link" class="icon" /> + + <.native_input type="url" id="url-basic" name="user[website]" class="native-input"> + <:label>Website + + <.native_input type="tel" id="tel-with-icon" name="user[phone]" class="native-input"> + <:label>Phone + <:icon><.heroicon name="hero-phone" class="icon" /> + + <.native_input type="tel" id="tel-basic" name="user[phone]" class="native-input"> + <:label>Phone + + <.native_input + type="search" + id="search-with-icon" + name="q" + class="native-input" + placeholder="Search" + > + <:label>Search + <:icon><.heroicon name="hero-magnifying-glass" class="icon" /> + + <.native_input + type="search" + id="search-basic" + name="q" + class="native-input" + placeholder="Search" + > + <:label>Search + + <.native_input + type="password" + id="password-with-icon" + name="user[password]" + class="native-input" + > + <:label>Password + <:icon><.heroicon name="hero-lock-closed" class="icon" /> + + <.native_input type="password" id="password-basic" name="user[password]" class="native-input"> + <:label>Password + + <.native_input + type="number" + id="number" + name="user[count]" + value="42" + min="0" + max="100" + step="1" + class="native-input" + > + <:label>Number + +
+ """ + end + + def anatomy_date_time_code do + ~S""" +
+ <.native_input type="date" id="date" name="user[date]" class="native-input"> + <:label>Date + + <.native_input type="datetime-local" id="datetime" name="user[datetime]" class="native-input"> + <:label>Date and time + + <.native_input type="time" id="time" name="user[time]" class="native-input"> + <:label>Time + + <.native_input type="month" id="month" name="user[month]" class="native-input"> + <:label>Month + + <.native_input type="week" id="week" name="user[week]" class="native-input"> + <:label>Week + +
+ """ + end + + def anatomy_date_time_example(assigns) do + _ = assigns + + ~H""" +
+ <.native_input type="date" id="date" name="user[date]" class="native-input"> + <:label>Date + + <.native_input type="datetime-local" id="datetime" name="user[datetime]" class="native-input"> + <:label>Date and time + + <.native_input type="time" id="time" name="user[time]" class="native-input"> + <:label>Time + + <.native_input type="month" id="month" name="user[month]" class="native-input"> + <:label>Month + + <.native_input type="week" id="week" name="user[week]" class="native-input"> + <:label>Week + +
+ """ + end + + def anatomy_multiple_code do + ~S""" +
+ <.native_input + type="select" + multiple + id="select-multiple" + name="user[tags][]" + options={[ + {"Elixir", "elixir"}, + {"Phoenix", "phoenix"}, + {"LiveView", "liveview"}, + {"Ecto", "ecto"}, + {"OTP", "otp"} + ]} + prompt="Choose tags..." + class="native-input" + > + <:label>Tags + +
+ """ + end + + def anatomy_multiple_example(assigns) do + _ = assigns + + ~H""" +
+ <.native_input + type="select" + multiple + id="select-multiple" + name="user[tags][]" + options={[ + {"Elixir", "elixir"}, + {"Phoenix", "phoenix"}, + {"LiveView", "liveview"}, + {"Ecto", "ecto"}, + {"OTP", "otp"} + ]} + prompt="Choose tags..." + class="native-input" + > + <:label>Tags + +
+ """ + end + + def anatomy_other_code do + ~S""" +
+ <.native_input type="checkbox" id="checkbox" name="user[agree]" class="native-input"> + <:label>I agree + + <.native_input type="color" id="color" name="user[color]" value="#3b82f6" class="native-input"> + <:label>Color + + <.native_input + type="radio" + id="radio" + name="user[size]" + options={[{"Small", "s"}, {"Medium", "m"}, {"Large", "l"}]} + value="m" + class="native-input" + > + <:label>Size + + <.native_input + type="select" + id="select" + name="user[role]" + options={[{"Admin", "admin"}, {"User", "user"}]} + prompt="Choose a role..." + class="native-input" + > + <:label>Role + +
+ """ + end + + def anatomy_other_example(assigns) do + _ = assigns + + ~H""" +
+ <.native_input type="checkbox" id="checkbox" name="user[agree]" class="native-input"> + <:label>I agree + + <.native_input type="color" id="color" name="user[color]" value="#3b82f6" class="native-input"> + <:label>Color + + <.native_input + type="radio" + id="radio" + name="user[size]" + options={[{"Small", "s"}, {"Medium", "m"}, {"Large", "l"}]} + value="m" + class="native-input" + > + <:label>Size + + <.native_input + type="select" + id="select" + name="user[role]" + options={[{"Admin", "admin"}, {"User", "user"}]} + prompt="Choose a role..." + class="native-input" + > + <:label>Role + +
+ """ + end + + def showcase_code do + [ + anatomy_text_code(), + anatomy_date_time_code(), + anatomy_multiple_code(), + anatomy_other_code() + ] + |> Enum.join("\n") + end + + def showcase_example(assigns) do + ~H""" +
+ {anatomy_text_example(assigns)} + {anatomy_date_time_example(assigns)} + {anatomy_multiple_example(assigns)} + {anatomy_other_example(assigns)} +
+ """ + end + + def form_ecto do + ~S""" + defmodule MyApp.Forms.NativeInputProfile do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :name, :string + field :email, :string + field :bio, :string + field :birth_date, :string + field :datetime, :string + field :reminder_time, :string + field :month, :string + field :week, :string + field :website, :string + field :phone, :string + field :q, :string + field :color, :string + field :count, :integer + field :password, :string + field :role, :string + field :tags, {:array, :string}, default: [] + field :size, :string + field :agree, :boolean, default: false + end + + def changeset(profile, attrs \\ %{}) do + profile + |> cast(attrs, [:name, :email, :bio, :birth_date, :datetime, :reminder_time, :month, :week, :website, :phone, :q, :color, :count, :password, :role, :tags, :size, :agree]) + |> validate_required([:name, :email, :agree]) + |> validate_acceptance(:agree) + end + + def changeset_validate(profile, attrs \\ %{}) do + profile + |> cast(attrs, [:name, :email, :bio, :birth_date, :datetime, :reminder_time, :month, :week, :website, :phone, :q, :color, :count, :password, :role, :tags, :size, :agree]) + |> validate_required([:name, :email, :role, :count, :agree], message: "can't be blank") + |> validate_format(:email, ~r/@/, message: "must look like an email address") + |> validate_length(:bio, min: 3, message: "must be at least 3 characters") + |> validate_number(:count, greater_than: 0, less_than: 99, message: "must be between 1 and 98") + |> validate_acceptance(:agree, message: "must be accepted to continue") + end + end + """ + end + + def form_changeset_heex, do: form_doc_controller_changeset_heex() + def form_changeset_elixir, do: form_doc_controller_changeset_elixir() + def form_validate_heex, do: form_doc_controller_validate_heex() + def form_validate_elixir, do: form_doc_controller_validate_elixir() + def form_native_heex, do: form_doc_native_heex() + + def form_doc_controller_changeset_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/native-input/form"} + method="post" + id={@form.id} + class="flex flex-col gap-6 w-full max-w-lg" + > + +
+

Text

+ <.native_input field={f[:name]} type="text" id="native-input-changeset-name" placeholder="Your name" class="native-input"> + <:label>Name + <:error :let={msg}>{msg} + + <.native_input field={f[:email]} type="email" id="native-input-changeset-email" placeholder="you@example.com" class="native-input"> + <:label>Email + <:error :let={msg}>{msg} + + <.native_input field={f[:bio]} type="textarea" id="native-input-changeset-bio" placeholder="Short bio" class="native-input"> + <:label>Bio + <:error :let={msg}>{msg} + + <.native_input field={f[:website]} type="url" id="native-input-changeset-website" placeholder="https://example.com" class="native-input"> + <:label>Website + <:error :let={msg}>{msg} + + <.native_input field={f[:phone]} type="tel" id="native-input-changeset-phone" placeholder="+1 234 567 8900" class="native-input"> + <:label>Phone + <:error :let={msg}>{msg} + + <.native_input field={f[:q]} type="search" id="native-input-changeset-q" placeholder="Search" class="native-input"> + <:label>Search + <:error :let={msg}>{msg} + + <.native_input field={f[:count]} type="number" id="native-input-changeset-count" min={0} max={100} step={1} class="native-input"> + <:label>Count + <:error :let={msg}>{msg} + + <.native_input field={f[:password]} type="password" id="native-input-changeset-password" class="native-input"> + <:label>Password + <:error :let={msg}>{msg} + +
+
+

Date & time

+ <.native_input field={f[:birth_date]} type="date" id="native-input-changeset-birth-date" class="native-input"> + <:label>Birth date + <:error :let={msg}>{msg} + + <.native_input field={f[:datetime]} type="datetime-local" id="native-input-changeset-datetime" class="native-input"> + <:label>Date and time + <:error :let={msg}>{msg} + + <.native_input field={f[:reminder_time]} type="time" id="native-input-changeset-reminder-time" class="native-input"> + <:label>Reminder time + <:error :let={msg}>{msg} + + <.native_input field={f[:month]} type="month" id="native-input-changeset-month" class="native-input"> + <:label>Month + <:error :let={msg}>{msg} + + <.native_input field={f[:week]} type="week" id="native-input-changeset-week" class="native-input"> + <:label>Week + <:error :let={msg}>{msg} + +
+
+

Multiple

+ <.native_input + field={f[:tags]} + type="select" + multiple + id="native-input-changeset-tags" + options={[ + "Elixir": "elixir", + Phoenix: "phoenix", + LiveView: "liveview", + Ecto: "ecto", + OTP: "otp" + ]} + prompt="Choose tags..." + class="native-input" + > + <:label>Tags + <:error :let={msg}>{msg} + +
+
+

Other

+ <.native_input field={f[:color]} type="color" id="native-input-changeset-color" value="#3b82f6" class="native-input"> + <:label>Color + <:error :let={msg}>{msg} + + <.native_input + field={f[:role]} + type="select" + id="native-input-changeset-role" + options={[Admin: "admin", User: "user"]} + prompt="Choose role..." + class="native-input" + > + <:label>Role + <:error :let={msg}>{msg} + + <.native_input + field={f[:size]} + type="radio" + id="native-input-changeset-size" + value="m" + options={[Small: "s", Medium: "m", Large: "l"]} + class="native-input" + > + <:label>Size + <:error :let={msg}>{msg} + + <.native_input field={f[:agree]} type="checkbox" id="native-input-changeset-agree" class="native-input"> + <:label>I agree + <:error :let={msg}>{msg} + +
+ <.action type="submit" id="native-input-changeset-submit" class="button button--accent"> + Submit + + + """ + end + + def form_doc_controller_changeset_elixir do + ~S""" + def native_input_form_page(conn, _params) do + changeset = MyApp.Forms.NativeInputProfile.changeset(%MyApp.Forms.NativeInputProfile{}, %{}) + + validate_changeset = + MyApp.Forms.NativeInputProfile.changeset_validate(%MyApp.Forms.NativeInputProfile{}, %{}) + + form = + Phoenix.Component.to_form(changeset, + as: :profile_changeset, + id: "native-input-changeset-form" + ) + + validate_form = + Phoenix.Component.to_form(validate_changeset, + as: :profile_validate, + id: "native-input-validate-form" + ) + + render(conn, :native_input_form_page, form: form, validate_form: validate_form) + end + + def native_input_form_create(conn, %{"profile_changeset" => params}) do + case MyApp.Forms.NativeInputProfile.changeset(%MyApp.Forms.NativeInputProfile{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + _data = Ecto.Changeset.apply_changes(changeset) + conn + |> put_flash(:info, "Saved profile") + |> redirect(to: ~p"/native-input/form#native-input-form-changeset") + + changeset -> + changeset = Map.put(changeset, :action, :insert) + form = Phoenix.Component.to_form(changeset, as: :profile_changeset, id: "native-input-changeset-form") + + validate_form = + MyApp.Forms.NativeInputProfile.changeset_validate(%MyApp.Forms.NativeInputProfile{}, %{}) + |> Phoenix.Component.to_form(as: :profile_validate, id: "native-input-validate-form") + + render(conn, :native_input_form_page, form: form, validate_form: validate_form) + end + end + """ + end + + def form_doc_controller_validate_heex do + form_doc_controller_changeset_heex() + |> String.replace("native-input-changeset", "native-input-validate") + end + + def form_doc_controller_validate_elixir do + ~S""" + def native_input_form_strict_create(conn, %{"profile_validate" => params}) do + case MyApp.Forms.NativeInputProfile.changeset_validate(%MyApp.Forms.NativeInputProfile{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + _data = Ecto.Changeset.apply_changes(changeset) + conn + |> put_flash(:info, "Saved profile (strict)") + |> redirect(to: ~p"/native-input/form#native-input-form-validate") + + changeset -> + changeset = Map.put(changeset, :action, :insert) + + validate_form = + Phoenix.Component.to_form(changeset, as: :profile_validate, id: "native-input-validate-form") + + form = + MyApp.Forms.NativeInputProfile.changeset(%MyApp.Forms.NativeInputProfile{}, %{}) + |> Phoenix.Component.to_form(as: :profile_changeset, id: "native-input-changeset-form") + + render(conn, :native_input_form_page, form: form, validate_form: validate_form) + end + end + """ + end + + def form_doc_native_heex do + ~S""" +
+ +
+

Text

+ <.native_input type="text" name="profile[name]" id="native-input-form-name" placeholder="Your name" class="native-input"> + <:label>Name + + <.native_input type="email" name="profile[email]" id="native-input-form-email" placeholder="you@example.com" class="native-input"> + <:label>Email + + <.native_input type="textarea" name="profile[bio]" id="native-input-form-bio" placeholder="Short bio" class="native-input"> + <:label>Bio + + <.native_input type="url" name="profile[website]" id="native-input-form-website" placeholder="https://example.com" class="native-input"> + <:label>Website + + <.native_input type="tel" name="profile[phone]" id="native-input-form-phone" placeholder="+1 234 567 8900" class="native-input"> + <:label>Phone + + <.native_input type="search" name="profile[q]" id="native-input-form-q" placeholder="Search" class="native-input"> + <:label>Search + + <.native_input type="number" name="profile[count]" id="native-input-form-count" min={0} max={100} step={1} class="native-input"> + <:label>Count + + <.native_input type="password" name="profile[password]" id="native-input-form-password" class="native-input"> + <:label>Password + +
+
+

Date & time

+ <.native_input type="date" name="profile[birth_date]" id="native-input-form-birth-date" class="native-input"> + <:label>Birth date + + <.native_input type="datetime-local" name="profile[datetime]" id="native-input-form-datetime" class="native-input"> + <:label>Date and time + + <.native_input type="time" name="profile[reminder_time]" id="native-input-form-reminder-time" class="native-input"> + <:label>Reminder time + + <.native_input type="month" name="profile[month]" id="native-input-form-month" class="native-input"> + <:label>Month + + <.native_input type="week" name="profile[week]" id="native-input-form-week" class="native-input"> + <:label>Week + +
+
+

Multiple

+ <.native_input + type="select" + multiple + name="profile[tags][]" + id="native-input-form-tags" + options={[ + "Elixir": "elixir", + Phoenix: "phoenix", + LiveView: "liveview", + Ecto: "ecto", + OTP: "otp" + ]} + prompt="Choose tags..." + class="native-input" + > + <:label>Tags + +
+
+

Other

+ <.native_input type="color" name="profile[color]" id="native-input-form-color" value="#3b82f6" class="native-input"> + <:label>Color + + <.native_input type="select" name="profile[role]" id="native-input-form-role" options={[Admin: "admin", User: "user"]} prompt="Choose role..." class="native-input"> + <:label>Role + + <.native_input type="radio" name="profile[size]" id="native-input-form-size" value="m" options={[Small: "s", Medium: "m", Large: "l"]} class="native-input"> + <:label>Size + + <.native_input type="checkbox" name="profile[agree]" id="native-input-form-agree" class="native-input"> + <:label>I agree + +
+ <.action type="submit" id="native-input-form-submit" class="button button--accent"> + Submit + +
+ """ + end + + attr(:form, :any, required: true) + + def form_preview_controller_changeset(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/native-input/form"} + method="post" + id={@form.id} + class="flex flex-col gap-6 w-full max-w-lg" + > + +
+

Text

+ <.native_input + field={f[:name]} + type="text" + id="native-input-changeset-name" + placeholder="Your name" + class="native-input" + > + <:label>Name + <:error :let={msg}>{msg} + + <.native_input + field={f[:email]} + type="email" + id="native-input-changeset-email" + placeholder="you@example.com" + class="native-input" + > + <:label>Email + <:error :let={msg}>{msg} + + <.native_input + field={f[:bio]} + type="textarea" + id="native-input-changeset-bio" + placeholder="Short bio" + class="native-input" + > + <:label>Bio + <:error :let={msg}>{msg} + + <.native_input + field={f[:website]} + type="url" + id="native-input-changeset-website" + placeholder="https://example.com" + class="native-input" + > + <:label>Website + <:error :let={msg}>{msg} + + <.native_input + field={f[:phone]} + type="tel" + id="native-input-changeset-phone" + placeholder="+1 234 567 8900" + class="native-input" + > + <:label>Phone + <:error :let={msg}>{msg} + + <.native_input + field={f[:q]} + type="search" + id="native-input-changeset-q" + placeholder="Search" + class="native-input" + > + <:label>Search + <:error :let={msg}>{msg} + + <.native_input + field={f[:count]} + type="number" + id="native-input-changeset-count" + min={0} + max={100} + step={1} + class="native-input" + > + <:label>Count + <:error :let={msg}>{msg} + + <.native_input + field={f[:password]} + type="password" + id="native-input-changeset-password" + class="native-input" + > + <:label>Password + <:error :let={msg}>{msg} + +
+
+

Date & time

+ <.native_input + field={f[:birth_date]} + type="date" + id="native-input-changeset-birth-date" + class="native-input" + > + <:label>Birth date + <:error :let={msg}>{msg} + + <.native_input + field={f[:datetime]} + type="datetime-local" + id="native-input-changeset-datetime" + class="native-input" + > + <:label>Date and time + <:error :let={msg}>{msg} + + <.native_input + field={f[:reminder_time]} + type="time" + id="native-input-changeset-reminder-time" + class="native-input" + > + <:label>Reminder time + <:error :let={msg}>{msg} + + <.native_input + field={f[:month]} + type="month" + id="native-input-changeset-month" + class="native-input" + > + <:label>Month + <:error :let={msg}>{msg} + + <.native_input + field={f[:week]} + type="week" + id="native-input-changeset-week" + class="native-input" + > + <:label>Week + <:error :let={msg}>{msg} + +
+
+

Multiple

+ <.native_input + field={f[:tags]} + type="select" + multiple + id="native-input-changeset-tags" + options={tag_options()} + prompt="Choose tags..." + class="native-input" + > + <:label>Tags + <:error :let={msg}>{msg} + +
+
+

Other

+ <.native_input + field={f[:color]} + type="color" + id="native-input-changeset-color" + value="#3b82f6" + class="native-input" + > + <:label>Color + <:error :let={msg}>{msg} + + <.native_input + field={f[:role]} + type="select" + id="native-input-changeset-role" + options={[Admin: "admin", User: "user"]} + prompt="Choose role..." + class="native-input" + > + <:label>Role + <:error :let={msg}>{msg} + + <.native_input + field={f[:size]} + type="radio" + id="native-input-changeset-size" + value="m" + options={[Small: "s", Medium: "m", Large: "l"]} + class="native-input" + > + <:label>Size + <:error :let={msg}>{msg} + + <.native_input + field={f[:agree]} + type="checkbox" + id="native-input-changeset-agree" + class="native-input" + > + <:label>I agree + <:error :let={msg}>{msg} + +
+ <.action type="submit" id="native-input-changeset-submit" class="button button--accent"> + Submit + + + """ + end + + attr(:form, :any, required: true) + + def form_preview_controller_validate(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/native-input/form"} + method="post" + id={@form.id} + class="flex flex-col gap-6 w-full max-w-lg" + > + +
+

Text

+ <.native_input + field={f[:name]} + type="text" + id="native-input-validate-name" + placeholder="Your name" + class="native-input" + > + <:label>Name + <:error :let={msg}>{msg} + + <.native_input + field={f[:email]} + type="email" + id="native-input-validate-email" + placeholder="you@example.com" + class="native-input" + > + <:label>Email + <:error :let={msg}>{msg} + + <.native_input + field={f[:bio]} + type="textarea" + id="native-input-validate-bio" + placeholder="Short bio" + class="native-input" + > + <:label>Bio + <:error :let={msg}>{msg} + + <.native_input + field={f[:website]} + type="url" + id="native-input-validate-website" + placeholder="https://example.com" + class="native-input" + > + <:label>Website + <:error :let={msg}>{msg} + + <.native_input + field={f[:phone]} + type="tel" + id="native-input-validate-phone" + placeholder="+1 234 567 8900" + class="native-input" + > + <:label>Phone + <:error :let={msg}>{msg} + + <.native_input + field={f[:q]} + type="search" + id="native-input-validate-q" + placeholder="Search" + class="native-input" + > + <:label>Search + <:error :let={msg}>{msg} + + <.native_input + field={f[:count]} + type="number" + id="native-input-validate-count" + min={0} + max={100} + step={1} + class="native-input" + > + <:label>Count + <:error :let={msg}>{msg} + + <.native_input + field={f[:password]} + type="password" + id="native-input-validate-password" + class="native-input" + > + <:label>Password + <:error :let={msg}>{msg} + +
+
+

Date & time

+ <.native_input + field={f[:birth_date]} + type="date" + id="native-input-validate-birth-date" + class="native-input" + > + <:label>Birth date + <:error :let={msg}>{msg} + + <.native_input + field={f[:datetime]} + type="datetime-local" + id="native-input-validate-datetime" + class="native-input" + > + <:label>Date and time + <:error :let={msg}>{msg} + + <.native_input + field={f[:reminder_time]} + type="time" + id="native-input-validate-reminder-time" + class="native-input" + > + <:label>Reminder time + <:error :let={msg}>{msg} + + <.native_input + field={f[:month]} + type="month" + id="native-input-validate-month" + class="native-input" + > + <:label>Month + <:error :let={msg}>{msg} + + <.native_input + field={f[:week]} + type="week" + id="native-input-validate-week" + class="native-input" + > + <:label>Week + <:error :let={msg}>{msg} + +
+
+

Multiple

+ <.native_input + field={f[:tags]} + type="select" + multiple + id="native-input-validate-tags" + options={tag_options()} + prompt="Choose tags..." + class="native-input" + > + <:label>Tags + <:error :let={msg}>{msg} + +
+
+

Other

+ <.native_input + field={f[:color]} + type="color" + id="native-input-validate-color" + value="#3b82f6" + class="native-input" + > + <:label>Color + <:error :let={msg}>{msg} + + <.native_input + field={f[:role]} + type="select" + id="native-input-validate-role" + options={[Admin: "admin", User: "user"]} + prompt="Choose role..." + class="native-input" + > + <:label>Role + <:error :let={msg}>{msg} + + <.native_input + field={f[:size]} + type="radio" + id="native-input-validate-size" + value="m" + options={[Small: "s", Medium: "m", Large: "l"]} + class="native-input" + > + <:label>Size + <:error :let={msg}>{msg} + + <.native_input + field={f[:agree]} + type="checkbox" + id="native-input-validate-agree" + class="native-input" + > + <:label>I agree + <:error :let={msg}>{msg} + +
+ <.action type="submit" id="native-input-validate-submit" class="button button--accent"> + Submit + + + """ + end + + def form_preview_controller_native(assigns) do + _ = assigns + + ~H""" +
+ +
+

Text

+ <.native_input + type="text" + name="profile[name]" + id="native-input-form-name" + placeholder="Your name" + class="native-input" + > + <:label>Name + + <.native_input + type="email" + name="profile[email]" + id="native-input-form-email" + placeholder="you@example.com" + class="native-input" + > + <:label>Email + + <.native_input + type="textarea" + name="profile[bio]" + id="native-input-form-bio" + placeholder="Short bio" + class="native-input" + > + <:label>Bio + + <.native_input + type="url" + name="profile[website]" + id="native-input-form-website" + placeholder="https://example.com" + class="native-input" + > + <:label>Website + + <.native_input + type="tel" + name="profile[phone]" + id="native-input-form-phone" + placeholder="+1 234 567 8900" + class="native-input" + > + <:label>Phone + + <.native_input + type="search" + name="profile[q]" + id="native-input-form-q" + placeholder="Search" + class="native-input" + > + <:label>Search + + <.native_input + type="number" + name="profile[count]" + id="native-input-form-count" + min={0} + max={100} + step={1} + class="native-input" + > + <:label>Count + + <.native_input + type="password" + name="profile[password]" + id="native-input-form-password" + class="native-input" + > + <:label>Password + +
+
+

Date & time

+ <.native_input + type="date" + name="profile[birth_date]" + id="native-input-form-birth-date" + class="native-input" + > + <:label>Birth date + + <.native_input + type="datetime-local" + name="profile[datetime]" + id="native-input-form-datetime" + class="native-input" + > + <:label>Date and time + + <.native_input + type="time" + name="profile[reminder_time]" + id="native-input-form-reminder-time" + class="native-input" + > + <:label>Reminder time + + <.native_input + type="month" + name="profile[month]" + id="native-input-form-month" + class="native-input" + > + <:label>Month + + <.native_input + type="week" + name="profile[week]" + id="native-input-form-week" + class="native-input" + > + <:label>Week + +
+
+

Multiple

+ <.native_input + type="select" + multiple + name="profile[tags][]" + id="native-input-form-tags" + options={tag_options()} + prompt="Choose tags..." + class="native-input" + > + <:label>Tags + +
+
+

Other

+ <.native_input + type="color" + name="profile[color]" + id="native-input-form-color" + value="#3b82f6" + class="native-input" + > + <:label>Color + + <.native_input + type="select" + name="profile[role]" + id="native-input-form-role" + options={[Admin: "admin", User: "user"]} + prompt="Choose role..." + class="native-input" + > + <:label>Role + + <.native_input + type="radio" + name="profile[size]" + id="native-input-form-size" + value="m" + options={[Small: "s", Medium: "m", Large: "l"]} + class="native-input" + > + <:label>Size + + <.native_input + type="checkbox" + name="profile[agree]" + id="native-input-form-agree" + class="native-input" + > + <:label>I agree + +
+ <.action type="submit" id="native-input-form-submit" class="button button--accent"> + Submit + +
+ """ + end + + def form_doc_live_changeset_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="flex flex-col gap-6 w-full max-w-lg" + > +
+

Text

+ <.native_input field={@form[:name]} type="text" id="native-input-form-name" placeholder="Your name" class="native-input"> + <:label>Name + <:error :let={msg}>{msg} + +
+ + """ + end + + def form_doc_live_changeset_elixir do + ~S""" + def handle_event("validate", %{"profile" => params}, socket) do + changeset = + %MyApp.Forms.NativeInputProfile{} + |> MyApp.Forms.NativeInputProfile.changeset(params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :form, Phoenix.Component.to_form(changeset, action: :validate, as: :profile, id: "native-input-live-profile-form"))} + end + + def handle_event("save", %{"profile" => params}, socket) do + case MyApp.Forms.NativeInputProfile.changeset(%MyApp.Forms.NativeInputProfile{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + _data = Ecto.Changeset.apply_changes(changeset) + {:noreply, assign(socket, :form, Phoenix.Component.to_form(MyApp.Forms.NativeInputProfile.changeset(%MyApp.Forms.NativeInputProfile{}, %{}), as: :profile, id: "native-input-live-profile-form"))} + + changeset -> + {:noreply, assign(socket, :form, Phoenix.Component.to_form(changeset, action: :insert, as: :profile, id: "native-input-live-profile-form"))} + end + end + """ + end + + def form_doc_live_validate_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate_strict" + phx-submit="save_strict" + class="flex flex-col gap-6 w-full max-w-lg" + > +
+

Text

+ <.native_input field={@form[:name]} type="text" id="native-input-strict-name" placeholder="Your name" class="native-input"> + <:label>Name + <:error :let={msg}>{msg} + +
+ + """ + end + + def form_doc_live_validate_elixir do + ~S""" + def handle_event("validate_strict", %{"profile_strict" => params}, socket) do + changeset = + %MyApp.Forms.NativeInputProfile{} + |> MyApp.Forms.NativeInputProfile.changeset_validate(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form(changeset, action: :validate, as: :profile_strict, id: "native-input-live-strict-form") + )} + end + + def handle_event("save_strict", %{"profile_strict" => params}, socket) do + case MyApp.Forms.NativeInputProfile.changeset_validate(%MyApp.Forms.NativeInputProfile{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + _data = Ecto.Changeset.apply_changes(changeset) + + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form( + MyApp.Forms.NativeInputProfile.changeset_validate(%MyApp.Forms.NativeInputProfile{}, %{}), + as: :profile_strict, + id: "native-input-live-strict-form" + ) + )} + + changeset -> + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form(changeset, action: :insert, as: :profile_strict, id: "native-input-live-strict-form") + )} + end + end + """ + end + + attr(:form, :any, required: true) + + def form_preview_live_changeset(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="flex flex-col gap-6 w-full max-w-lg" + > +
+

Text

+ <.native_input + field={@form[:name]} + type="text" + id="native-input-form-name" + placeholder="Your name" + class="native-input" + > + <:label>Name + <:error :let={msg}>{msg} + + <.native_input + field={@form[:email]} + type="email" + id="native-input-form-email" + placeholder="you@example.com" + class="native-input" + > + <:label>Email + <:error :let={msg}>{msg} + + <.native_input + field={@form[:bio]} + type="textarea" + id="native-input-form-bio" + placeholder="Short bio" + class="native-input" + > + <:label>Bio + <:error :let={msg}>{msg} + + <.native_input + field={@form[:website]} + type="url" + id="native-input-form-website" + placeholder="https://example.com" + class="native-input" + > + <:label>Website + <:error :let={msg}>{msg} + + <.native_input + field={@form[:phone]} + type="tel" + id="native-input-form-phone" + placeholder="+1 234 567 8900" + class="native-input" + > + <:label>Phone + <:error :let={msg}>{msg} + + <.native_input + field={@form[:q]} + type="search" + id="native-input-form-q" + placeholder="Search" + class="native-input" + > + <:label>Search + <:error :let={msg}>{msg} + + <.native_input + field={@form[:count]} + type="number" + id="native-input-form-count" + min={0} + max={100} + step={1} + class="native-input" + > + <:label>Count + <:error :let={msg}>{msg} + + <.native_input + field={@form[:password]} + type="password" + id="native-input-form-password" + class="native-input" + > + <:label>Password + <:error :let={msg}>{msg} + +
+
+

Date & time

+ <.native_input + field={@form[:birth_date]} + type="date" + id="native-input-form-birth-date" + class="native-input" + > + <:label>Birth date + <:error :let={msg}>{msg} + + <.native_input + field={@form[:datetime]} + type="datetime-local" + id="native-input-form-datetime" + class="native-input" + > + <:label>Date and time + <:error :let={msg}>{msg} + + <.native_input + field={@form[:reminder_time]} + type="time" + id="native-input-form-reminder-time" + class="native-input" + > + <:label>Reminder time + <:error :let={msg}>{msg} + + <.native_input + field={@form[:month]} + type="month" + id="native-input-form-month" + class="native-input" + > + <:label>Month + <:error :let={msg}>{msg} + + <.native_input + field={@form[:week]} + type="week" + id="native-input-form-week" + class="native-input" + > + <:label>Week + <:error :let={msg}>{msg} + +
+
+

Multiple

+ <.native_input + field={@form[:tags]} + type="select" + multiple + id="native-input-form-tags" + options={tag_options()} + prompt="Choose tags..." + class="native-input" + > + <:label>Tags + <:error :let={msg}>{msg} + +
+
+

Other

+ <.native_input + field={@form[:color]} + type="color" + id="native-input-form-color" + value="#3b82f6" + class="native-input" + > + <:label>Color + <:error :let={msg}>{msg} + + <.native_input + field={@form[:role]} + type="select" + id="native-input-form-role" + options={[Admin: "admin", User: "user"]} + prompt="Choose role..." + class="native-input" + > + <:label>Role + <:error :let={msg}>{msg} + + <.native_input + field={@form[:size]} + type="radio" + id="native-input-form-size" + value="m" + options={[Small: "s", Medium: "m", Large: "l"]} + class="native-input" + > + <:label>Size + <:error :let={msg}>{msg} + + <.native_input + field={@form[:agree]} + type="checkbox" + id="native-input-form-agree" + class="native-input" + > + <:label>I agree + <:error :let={msg}>{msg} + +
+ <.action type="submit" id="native-input-form-live-submit" class="button button--accent"> + Submit + + + """ + end + + attr(:form, :any, required: true) + + def form_preview_live_validate(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate_strict" + phx-submit="save_strict" + class="flex flex-col gap-6 w-full max-w-lg" + > +
+

Text

+ <.native_input + field={@form[:name]} + type="text" + id="native-input-strict-name" + placeholder="Your name" + class="native-input" + > + <:label>Name + <:error :let={msg}>{msg} + + <.native_input + field={@form[:email]} + type="email" + id="native-input-strict-email" + placeholder="you@example.com" + class="native-input" + > + <:label>Email + <:error :let={msg}>{msg} + + <.native_input + field={@form[:bio]} + type="textarea" + id="native-input-strict-bio" + placeholder="Short bio (min 3 chars)" + class="native-input" + > + <:label>Bio + <:error :let={msg}>{msg} + + <.native_input + field={@form[:website]} + type="url" + id="native-input-strict-website" + placeholder="https://example.com" + class="native-input" + > + <:label>Website + <:error :let={msg}>{msg} + + <.native_input + field={@form[:phone]} + type="tel" + id="native-input-strict-phone" + placeholder="+1 234 567 8900" + class="native-input" + > + <:label>Phone + <:error :let={msg}>{msg} + + <.native_input + field={@form[:q]} + type="search" + id="native-input-strict-q" + placeholder="Search" + class="native-input" + > + <:label>Search + <:error :let={msg}>{msg} + + <.native_input + field={@form[:count]} + type="number" + id="native-input-strict-count" + min={0} + max={100} + step={1} + class="native-input" + > + <:label>Count (1–98) + <:error :let={msg}>{msg} + + <.native_input + field={@form[:password]} + type="password" + id="native-input-strict-password" + class="native-input" + > + <:label>Password + <:error :let={msg}>{msg} + +
+
+

Date & time

+ <.native_input + field={@form[:birth_date]} + type="date" + id="native-input-strict-birth-date" + class="native-input" + > + <:label>Birth date + <:error :let={msg}>{msg} + + <.native_input + field={@form[:datetime]} + type="datetime-local" + id="native-input-strict-datetime" + class="native-input" + > + <:label>Date and time + <:error :let={msg}>{msg} + + <.native_input + field={@form[:reminder_time]} + type="time" + id="native-input-strict-reminder-time" + class="native-input" + > + <:label>Reminder time + <:error :let={msg}>{msg} + + <.native_input + field={@form[:month]} + type="month" + id="native-input-strict-month" + class="native-input" + > + <:label>Month + <:error :let={msg}>{msg} + + <.native_input + field={@form[:week]} + type="week" + id="native-input-strict-week" + class="native-input" + > + <:label>Week + <:error :let={msg}>{msg} + +
+
+

Multiple

+ <.native_input + field={@form[:tags]} + type="select" + multiple + id="native-input-strict-tags" + options={tag_options()} + prompt="Choose tags..." + class="native-input" + > + <:label>Tags + <:error :let={msg}>{msg} + +
+
+

Other

+ <.native_input + field={@form[:color]} + type="color" + id="native-input-strict-color" + value="#3b82f6" + class="native-input" + > + <:label>Color + <:error :let={msg}>{msg} + + <.native_input + field={@form[:role]} + type="select" + id="native-input-strict-role" + options={[Admin: "admin", User: "user"]} + prompt="Choose role..." + class="native-input" + > + <:label>Role + <:error :let={msg}>{msg} + + <.native_input + field={@form[:size]} + type="radio" + id="native-input-strict-size" + value="m" + options={[Small: "s", Medium: "m", Large: "l"]} + class="native-input" + > + <:label>Size + <:error :let={msg}>{msg} + + <.native_input + field={@form[:agree]} + type="checkbox" + id="native-input-strict-agree" + class="native-input" + > + <:label>I agree + <:error :let={msg}>{msg} + +
+ <.action type="submit" id="native-input-form-live-strict-submit" class="button button--accent"> + Submit + + + """ + end + + def form_code, do: form_native_heex() +end diff --git a/e2e/lib/e2e_web/demos/navigate_demo.ex b/e2e/lib/e2e_web/demos/navigate_demo.ex new file mode 100644 index 00000000..2a578a20 --- /dev/null +++ b/e2e/lib/e2e_web/demos/navigate_demo.ex @@ -0,0 +1,107 @@ +defmodule E2eWeb.Demos.NavigateDemo do + use E2eWeb, :html + + def anatomy_basic_code do + ~S""" +
+ <.navigate to="#" class="link">Internal Link + <.navigate to="#" class="link"> + Internal Link + + + <.navigate to="#" class="link" aria_label="Internal link icon only"> + + +
+ """ + end + + def anatomy_basic_example(assigns) do + ~H""" +
+ <.navigate to="#" class="link">Internal Link + <.navigate to="#" class="link"> + Internal Link + + + <.navigate to="#" class="link" aria_label="Internal link icon only"> + + +
+ """ + end + + def anatomy_external_and_download_code do + ~S""" +
+ <.navigate to="https://example.com" class="link" external> + External Link + <.heroicon name="hero-arrow-top-right-on-square" class="icon" /> + + <.navigate to="#" class="link" download="report.pdf"> + Download Link + <.heroicon name="hero-arrow-down-tray" class="icon" /> + +
+ """ + end + + def anatomy_external_and_download_example(assigns) do + ~H""" +
+ <.navigate to="https://example.com" class="link" external> + External Link <.heroicon name="hero-arrow-top-right-on-square" class="icon" /> + + <.navigate to="#" class="link" download="report.pdf"> + Download Link <.heroicon name="hero-arrow-down-tray" class="icon" /> + +
+ """ + end + + def styling_color_code do + ~S""" +
+ <.navigate to="#" class="link link--accent">Accent + <.navigate to="#" class="link link--brand">Brand + <.navigate to="#" class="link link--alert">Alert + <.navigate to="#" class="link link--info">Info + <.navigate to="#" class="link link--success">Success +
+ """ + end + + def styling_color_example(assigns) do + ~H""" +
+ <.navigate to="#" class="link link--accent">Accent + <.navigate to="#" class="link link--brand">Brand + <.navigate to="#" class="link link--alert">Alert + <.navigate to="#" class="link link--info">Info + <.navigate to="#" class="link link--success">Success +
+ """ + end + + def styling_size_code do + ~S""" +
+ <.navigate to="#" class="link link--sm">Small + <.navigate to="#" class="link link--md">Medium + <.navigate to="#" class="link link--lg">Large + <.navigate to="#" class="link link--xl">XL +
+ """ + end + + def styling_size_example(assigns) do + ~H""" +
+ <.navigate to="#" class="link link--sm">Small + <.navigate to="#" class="link link--md">Medium + <.navigate to="#" class="link link--lg">Large + <.navigate to="#" class="link link--xl">XL +
+ """ + end +end diff --git a/e2e/lib/e2e_web/demos/number_input_demo.ex b/e2e/lib/e2e_web/demos/number_input_demo.ex new file mode 100644 index 00000000..14eab89b --- /dev/null +++ b/e2e/lib/e2e_web/demos/number_input_demo.ex @@ -0,0 +1,691 @@ +defmodule E2eWeb.Demos.NumberInputDemo do + use E2eWeb, :html + + def minimal_code do + ~S""" + <.number_input id="number-input-anatomy-minimal" class="number-input"> + <:label>Quantity + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def minimal_example(assigns) do + ~H""" + <.number_input id="number-input-anatomy-minimal" class="number-input"> + <:label>Quantity + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def min_max_default_code do + ~S""" + <.number_input + id="number-input-anatomy-bounds" + class="number-input" + min={0.0} + max={100.0} + step={5.0} + default_value="10" + > + <:label>Amount + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def min_max_default_example(assigns) do + ~H""" + <.number_input + id="number-input-anatomy-bounds" + class="number-input" + min={0.0} + max={100.0} + step={5.0} + default_value="10" + > + <:label>Amount + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def with_triggers_code, do: minimal_code() + def with_triggers_example(assigns), do: minimal_example(assigns) + + def styling_size_code do + ~S""" + <.number_input id="number-input-style-sm" class="number-input number-input--sm"> + <:label>SM + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + <.number_input id="number-input-style-lg" class="number-input number-input--lg"> + <:label>LG + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def styling_size_example(assigns) do + ~H""" +
+ <.number_input id="number-input-style-sm" class="number-input number-input--sm"> + <:label>SM + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + <.number_input id="number-input-style-lg" class="number-input number-input--lg"> + <:label>LG + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + +
+ """ + end + + def api_binding_heex do + ~S""" + <.number_input + id="number-input-api-binding" + class="number-input" + on_value_change="number_input_api_binding" + > + <:label>Quantity + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def api_binding_elixir do + ~S""" + def handle_event("number_input_api_binding", %{"id" => id, "value" => value} = payload, socket) do + n = payload["valueAsNumber"] + {:noreply, socket} + end + """ + end + + def api_binding_example(assigns) do + ~H""" + <.number_input + id="number-input-api-binding" + class="number-input" + on_value_change="number_input_api_binding" + > + <:label>Quantity + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def api_client_heex do + ~S""" + <.number_input + id="number-input-api-client" + class="number-input" + on_value_change_client="number-input-api-client-changed" + > + <:label>Quantity + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def api_client_js do + ~S""" + const el = document.getElementById("number-input-api-client"); + el?.addEventListener("number-input-api-client-changed", (event) => { + console.log(event.detail); + }); + """ + end + + def api_client_ts do + ~S""" + const el = document.getElementById("number-input-api-client"); + el?.addEventListener("number-input-api-client-changed", (event: Event) => { + console.log((event as CustomEvent<{ value?: string; valueAsNumber?: number }>).detail); + }); + """ + end + + def api_client_example(assigns) do + ~H""" + <.number_input + id="number-input-api-client" + class="number-input" + on_value_change_client="number-input-api-client-changed" + > + <:label>Quantity + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def api_server_note_heex do + ~S""" + <.number_input id="qty" class="number-input" default_value="1"> + <:label>Quantity + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def api_server_note_elixir do + ~S""" + # Initial value: pass default_value (or value) on the component. + # The hook reads data-default-value on the root element (the same id as id="qty"). + """ + end + + def api_server_note_example(assigns) do + ~H""" + <.number_input id="number-input-api-server-note" class="number-input" default_value="1"> + <:label>Quantity + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def api_overview_code, do: api_binding_heex() + def api_overview_example(assigns), do: api_binding_example(assigns) + + def events_server_heex do + ~S""" + <.number_input + id="number-input-events-server" + class="number-input" + on_value_change="number_input_changed" + > + <:label>Quantity + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("number_input_changed", %{"id" => id, "value" => value}, socket) do + log = %{time: "12:00:00", source: "server", value: inspect(value)} + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + ~S""" + <.number_input + id="number-input-events-client" + class="number-input" + on_value_change_client="number-input-changed" + > + <:label>Quantity + <:decrement_trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:increment_trigger><.heroicon name="hero-chevron-up" class="icon" /> + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("number-input-events-client"); + el?.addEventListener("number-input-changed", (event) => console.log(event.detail)); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById("number-input-events-client"); + el?.addEventListener("number-input-changed", (event: Event) => + console.log((event as CustomEvent).detail) + ); + """ + end + + def form_code, do: form_doc_controller_changeset_heex() + + def form_ecto do + ~S""" + defmodule MyApp.Forms.NumberInputForm do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :value, :float + end + + def changeset(form, attrs \\ %{}) do + form + |> cast(attrs, [:value]) + |> validate_required([:value]) + end + + def changeset_validate(form, attrs \\ %{}) do + form + |> cast(attrs, [:value]) + |> validate_required([:value]) + |> validate_number(:value, greater_than_or_equal_to: 1.0, less_than_or_equal_to: 9999.0) + end + end + """ + end + + def form_changeset_heex, do: form_doc_controller_changeset_heex() + def form_changeset_elixir, do: form_doc_controller_changeset_elixir() + def form_validate_heex, do: form_doc_controller_validate_heex() + def form_validate_elixir, do: form_doc_controller_validate_elixir() + def form_native_heex, do: form_doc_native_heex() + + def form_doc_controller_changeset_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/number-input/form"} + method="post" + id={@form.id} + class="flex flex-col gap-4 w-full max-w-lg" + > + + <.number_input field={f[:value]} id="number-input-changeset-field" class="number-input"> + <:label>Value + <:decrement_trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:increment_trigger> + <.heroicon name="hero-chevron-up" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="number-input-changeset-submit" class="button button--accent"> + Submit + + + """ + end + + def form_doc_controller_changeset_elixir do + ~S""" + def number_input_form_page(conn, _params) do + form = + MyApp.Forms.NumberInputForm.changeset(%MyApp.Forms.NumberInputForm{}, %{}) + |> Phoenix.Component.to_form(as: :number_input_changeset, id: "number-input-changeset-form") + + validate_form = + MyApp.Forms.NumberInputForm.changeset_validate(%MyApp.Forms.NumberInputForm{}, %{}) + |> Phoenix.Component.to_form(as: :number_input_validate, id: "number-input-validate-form") + + render(conn, :number_input_form_page, form: form, validate_form: validate_form) + end + + def number_input_form_create(conn, %{"number_input_changeset" => params}) do + case MyApp.Forms.NumberInputForm.changeset(%MyApp.Forms.NumberInputForm{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + _data = Ecto.Changeset.apply_changes(changeset) + conn + |> put_flash(:info, "Saved") + |> redirect(to: ~p"/number-input/form#number-input-form-changeset") + + changeset -> + changeset = Map.put(changeset, :action, :insert) + + form = + Phoenix.Component.to_form(changeset, + as: :number_input_changeset, + id: "number-input-changeset-form" + ) + + validate_form = + MyApp.Forms.NumberInputForm.changeset_validate(%MyApp.Forms.NumberInputForm{}, %{}) + |> Phoenix.Component.to_form(as: :number_input_validate, id: "number-input-validate-form") + + render(conn, :number_input_form_page, form: form, validate_form: validate_form) + end + end + """ + end + + def form_doc_controller_validate_heex do + form_doc_controller_changeset_heex() + |> String.replace("number-input-changeset", "number-input-validate") + end + + def form_doc_controller_validate_elixir do + ~S""" + def number_input_form_strict_create(conn, %{"number_input_validate" => params}) do + case MyApp.Forms.NumberInputForm.changeset_validate(%MyApp.Forms.NumberInputForm{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + _data = Ecto.Changeset.apply_changes(changeset) + conn + |> put_flash(:info, "Saved (strict)") + |> redirect(to: ~p"/number-input/form#number-input-form-validate") + + changeset -> + changeset = Map.put(changeset, :action, :insert) + + validate_form = + Phoenix.Component.to_form(changeset, + as: :number_input_validate, + id: "number-input-validate-form" + ) + + form = + MyApp.Forms.NumberInputForm.changeset(%MyApp.Forms.NumberInputForm{}, %{}) + |> Phoenix.Component.to_form(as: :number_input_changeset, id: "number-input-changeset-form") + + render(conn, :number_input_form_page, form: form, validate_form: validate_form) + end + end + """ + end + + def form_doc_native_heex do + ~S""" +
+ + + + +
+ """ + end + + def form_preview_controller_changeset(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/number-input/form"} + method="post" + id={@form.id} + class="flex flex-col gap-4 w-full max-w-lg" + > + <.number_input field={f[:value]} id="number-input-changeset-field" class="number-input"> + <:label>Value + <:decrement_trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:increment_trigger> + <.heroicon name="hero-chevron-up" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="number-input-changeset-submit" class="button button--accent"> + Submit + + + """ + end + + def form_preview_controller_validate(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/number-input/form"} + method="post" + id={@form.id} + class="flex flex-col gap-4 w-full max-w-lg" + > + <.number_input field={f[:value]} id="number-input-validate-field" class="number-input"> + <:label>Value (1–9999) + <:decrement_trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:increment_trigger> + <.heroicon name="hero-chevron-up" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="number-input-validate-submit" class="button button--accent"> + Submit + + + """ + end + + def form_preview_controller_native(assigns) do + _ = assigns + + ~H""" +
+ + + <.action type="submit" id="number-input-plain-submit" class="button button--accent"> + Submit + +
+ """ + end + + def form_doc_live_changeset_heex do + ~S""" + <.form for={@form} id={@form.id} phx-change="validate" phx-submit="save" class="flex flex-col gap-4 w-full max-w-lg"> + <.number_input field={@form[:value]} id="number-input-live-changeset-field" class="number-input"> + <:label>Value + <:decrement_trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:increment_trigger> + <.heroicon name="hero-chevron-up" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="number-input-form-live-changeset-submit" class="button button--accent"> + Submit + + + """ + end + + def form_doc_live_changeset_elixir do + ~S""" + def handle_event("validate", %{"number_input_changeset" => params}, socket) do + changeset = + %MyApp.Forms.NumberInputForm{} + |> MyApp.Forms.NumberInputForm.changeset(params) + |> Map.put(:action, :validate) + + {:noreply, + assign(socket, :form, Phoenix.Component.to_form(changeset, + action: :validate, + as: :number_input_changeset, + id: "number-input-live-changeset-form" + ))} + end + + def handle_event("save", %{"number_input_changeset" => params}, socket) do + case MyApp.Forms.NumberInputForm.changeset(%MyApp.Forms.NumberInputForm{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + data = Ecto.Changeset.apply_changes(changeset) + {:noreply, put_flash(socket, :info, "Submitted: #{inspect(data.value)}")} + + %Ecto.Changeset{} = changeset -> + {:noreply, + assign(socket, :form, Phoenix.Component.to_form(changeset, + action: :insert, + as: :number_input_changeset, + id: "number-input-live-changeset-form" + ))} + end + end + """ + end + + def form_doc_live_validate_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate_strict" + phx-submit="save_strict" + class="flex flex-col gap-4 w-full max-w-lg" + > + <.number_input field={@form[:value]} id="number-input-live-validate-field" class="number-input"> + <:label>Value (1–9999) + <:decrement_trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:increment_trigger> + <.heroicon name="hero-chevron-up" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="number-input-form-live-validate-submit" class="button button--accent"> + Submit + + + """ + end + + def form_doc_live_validate_elixir do + ~S""" + def handle_event("validate_strict", %{"number_input_validate" => params}, socket) do + changeset = + %MyApp.Forms.NumberInputForm{} + |> MyApp.Forms.NumberInputForm.changeset_validate(params) + |> Map.put(:action, :validate) + + {:noreply, + assign(socket, :strict_form, Phoenix.Component.to_form(changeset, + action: :validate, + as: :number_input_validate, + id: "number-input-live-validate-form" + ))} + end + + def handle_event("save_strict", %{"number_input_validate" => params}, socket) do + case MyApp.Forms.NumberInputForm.changeset_validate(%MyApp.Forms.NumberInputForm{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + data = Ecto.Changeset.apply_changes(changeset) + {:noreply, put_flash(socket, :info, "Submitted (strict): #{inspect(data.value)}")} + + %Ecto.Changeset{} = changeset -> + {:noreply, + assign(socket, :strict_form, Phoenix.Component.to_form(changeset, + action: :insert, + as: :number_input_validate, + id: "number-input-live-validate-form" + ))} + end + end + """ + end + + def form_preview_live_changeset(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="flex flex-col gap-4 w-full max-w-lg" + > + <.number_input field={@form[:value]} id="number-input-live-changeset-field" class="number-input"> + <:label>Value + <:decrement_trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:increment_trigger> + <.heroicon name="hero-chevron-up" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action + type="submit" + id="number-input-form-live-changeset-submit" + class="button button--accent" + > + Submit + + + """ + end + + def form_preview_live_validate(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate_strict" + phx-submit="save_strict" + class="flex flex-col gap-4 w-full max-w-lg" + > + <.number_input field={@form[:value]} id="number-input-live-validate-field" class="number-input"> + <:label>Value (1–9999) + <:decrement_trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:increment_trigger> + <.heroicon name="hero-chevron-up" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="number-input-form-live-validate-submit" class="button button--accent"> + Submit + + + """ + end +end diff --git a/e2e/lib/e2e_web/demos/password_input_demo.ex b/e2e/lib/e2e_web/demos/password_input_demo.ex new file mode 100644 index 00000000..4a28400c --- /dev/null +++ b/e2e/lib/e2e_web/demos/password_input_demo.ex @@ -0,0 +1,709 @@ +defmodule E2eWeb.Demos.PasswordInputDemo do + use E2eWeb, :html + + def minimal_code do + ~S""" + <.password_input id="password-input-anatomy-basic" name="user[password]" class="password-input"> + <:label>Password + + """ + end + + def minimal_example(assigns) do + ~H""" + <.password_input id="password-input-anatomy-basic" name="user[password]" class="password-input"> + <:label>Password + + """ + end + + def with_visibility_icons_code do + ~S""" + <.password_input id="password-input-anatomy-icons" name="user[password]" class="password-input"> + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + + """ + end + + def with_visibility_icons_example(assigns) do + ~H""" + <.password_input id="password-input-anatomy-icons" name="user[password]" class="password-input"> + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + + """ + end + + def api_binding_heex do + ~S""" + <.password_input + id="password-input-api-binding" + class="password-input" + name="user[password]" + on_visibility_change="password_input_api_visibility" + > + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + + """ + end + + def api_binding_elixir do + ~S""" + def handle_event("password_input_api_visibility", %{"id" => id, "visible" => visible}, socket) do + {:noreply, socket} + end + """ + end + + def api_binding_example(assigns) do + ~H""" + <.password_input + id="password-input-api-binding" + class="password-input" + name="user[password]" + on_visibility_change="password_input_api_visibility" + > + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + + """ + end + + def api_client_heex do + ~S""" + <.password_input + id="password-input-api-client" + class="password-input" + name="user[password]" + on_visibility_change_client="password-input-api-visibility-changed" + > + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + + """ + end + + def api_client_js do + ~S""" + const el = document.getElementById("password-input-api-client"); + el?.addEventListener("password-input-api-visibility-changed", (event) => { + console.log(event.detail); + }); + """ + end + + def api_client_ts do + ~S""" + const el = document.getElementById("password-input-api-client"); + el?.addEventListener("password-input-api-visibility-changed", (event: Event) => { + console.log((event as CustomEvent<{ id?: string; visible?: boolean }>).detail); + }); + """ + end + + def api_client_example(assigns) do + ~H""" + <.password_input + id="password-input-api-client" + class="password-input" + name="user[password]" + on_visibility_change_client="password-input-api-visibility-changed" + > + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + + """ + end + + def api_initial_heex do + ~S""" + <.password_input + id="password-input-api-initial" + class="password-input" + name="user[password]" + visible + > + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + + """ + end + + def api_initial_elixir do + ~S""" + # Initial visibility: pass visible={true} on the component. + # The hook reads data-default-visible on mount; LiveView does not control visibility after that. + """ + end + + def api_initial_example(assigns) do + ~H""" + <.password_input + id="password-input-api-initial" + class="password-input" + name="user[password]" + visible + > + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + + """ + end + + def api_overview_code, do: api_binding_heex() + def api_overview_example(assigns), do: api_binding_example(assigns) + + def events_server_heex do + ~S""" + <.password_input + id="password-input-events-server" + class="password-input" + name="user[password]" + on_visibility_change="password_visibility_changed" + > + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + + """ + end + + def events_server_elixir do + ~S""" + def handle_event(\"password_visibility_changed\", %{\"id\" => id, \"visible\" => visible}, socket) do + log = %{time: \"12:00:00\", source: \"server\", value: inspect(visible)} + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + ~S""" + <.password_input + id="password-input-events-client" + class="password-input" + name="user[password]" + on_visibility_change_client="password-visibility-changed" + > + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById(\"password-input-events-client\"); + el?.addEventListener(\"password-visibility-changed\", (event) => console.log(event.detail)); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById(\"password-input-events-client\"); + el?.addEventListener(\"password-visibility-changed\", (event: Event) => + console.log((event as CustomEvent).detail) + ); + """ + end + + def form_ecto do + ~S""" + defmodule MyApp.Forms.PasswordForm do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :password, :string, redact: true + end + + def changeset(form, attrs \\ %{}) do + form + |> cast(attrs, [:password]) + |> validate_required(:password) + end + + def changeset_validate(form, attrs \\ %{}) do + form + |> cast(attrs, [:password]) + |> validate_required([:password], message: "can't be blank") + |> validate_length(:password, min: 8, message: "must be at least 8 characters") + end + end + """ + end + + def form_doc_controller_changeset_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/account/password"} + method="post" + id={@form.id} + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.password_input field={f[:password]} class="password-input" id="account-password"> + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + + <.action type="submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_controller_changeset_elixir do + ~S""" + def account_password_page(conn, _params) do + changeset = MyApp.Forms.PasswordForm.changeset(%MyApp.Forms.PasswordForm{}, %{}) + + form = + Phoenix.Component.to_form(changeset, + as: :password_input_changeset, + id: "account-password-changeset-form" + ) + + render(conn, :account_password, form: form) + end + + def account_password_create(conn, %{"password_input_changeset" => params}) do + case MyApp.Forms.PasswordForm.changeset(%MyApp.Forms.PasswordForm{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + _data = Ecto.Changeset.apply_changes(changeset) + conn + |> put_flash(:info, "Saved") + |> redirect(to: ~p"/account") + + changeset -> + changeset = Map.put(changeset, :action, :insert) + + form = + Phoenix.Component.to_form(changeset, + as: :password_input_changeset, + id: "account-password-changeset-form" + ) + + render(conn, :account_password, form: form) + end + end + """ + end + + def form_doc_controller_validate_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/account/password-strict"} + method="post" + id={@form.id} + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.password_input field={f[:password]} class="password-input" id="account-password-strict"> + <:label>Password (stricter validation) + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + + <.action type="submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_controller_validate_elixir do + ~S""" + def account_password_strict_page(conn, _params) do + changeset = + MyApp.Forms.PasswordForm.changeset_validate(%MyApp.Forms.PasswordForm{}, %{}) + + form = + Phoenix.Component.to_form(changeset, + as: :password_input_validate, + id: "account-password-validate-form" + ) + + render(conn, :account_password_strict, form: form) + end + + def account_password_strict_create(conn, %{"password_input_validate" => params}) do + case MyApp.Forms.PasswordForm.changeset_validate(%MyApp.Forms.PasswordForm{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + _data = Ecto.Changeset.apply_changes(changeset) + conn + |> put_flash(:info, "Saved") + |> redirect(to: ~p"/account") + + changeset -> + changeset = Map.put(changeset, :action, :insert) + + form = + Phoenix.Component.to_form(changeset, + as: :password_input_validate, + id: "account-password-validate-form" + ) + + render(conn, :account_password_strict, form: form) + end + end + """ + end + + def form_doc_native_heex do + ~S""" +
+ + + <.action type="submit" class="button button--accent w-full">Submit +
+ """ + end + + def form_doc_live_changeset_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.password_input field={@form[:password]} class="password-input" id="password-input-live-changeset"> + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + + <.action type="submit" id="password-input-form-live-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_live_changeset_elixir do + ~S""" + def mount(_params, _session, socket) do + form = + %MyApp.Forms.PasswordForm{} + |> MyApp.Forms.PasswordForm.changeset(%{}) + |> Phoenix.Component.to_form(as: :password_input_live) + + {:ok, assign(socket, :form, form)} + end + + def handle_event("validate", %{"password_input_live" => params}, socket) do + changeset = + %MyApp.Forms.PasswordForm{} + |> MyApp.Forms.PasswordForm.changeset(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, action: :validate, as: :password_input_live) + )} + end + + def handle_event("save", %{"password_input_live" => params}, socket) do + case MyApp.Forms.PasswordForm.changeset(%MyApp.Forms.PasswordForm{}, params) do + %Ecto.Changeset{valid?: true} = _changeset -> + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form( + MyApp.Forms.PasswordForm.changeset(%MyApp.Forms.PasswordForm{}, %{}), + as: :password_input_live + ) + )} + + changeset -> + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, action: :insert, as: :password_input_live) + )} + end + end + """ + end + + def form_doc_live_validate_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate_strict" + phx-submit="save_strict" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.password_input field={@form[:password]} class="password-input" id="password-input-live-strict"> + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + + <.action type="submit" id="password-input-form-live-strict-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_live_validate_elixir do + ~S""" + def mount(_params, _session, socket) do + form = + %MyApp.Forms.PasswordForm{} + |> MyApp.Forms.PasswordForm.changeset_validate(%{}) + |> Phoenix.Component.to_form(as: :password_input_strict) + + {:ok, assign(socket, :strict_form, form)} + end + + def handle_event("validate_strict", %{"password_input_strict" => params}, socket) do + changeset = + %MyApp.Forms.PasswordForm{} + |> MyApp.Forms.PasswordForm.changeset_validate(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form(changeset, action: :validate, as: :password_input_strict) + )} + end + + def handle_event("save_strict", %{"password_input_strict" => params}, socket) do + case MyApp.Forms.PasswordForm.changeset_validate(%MyApp.Forms.PasswordForm{}, params) do + %Ecto.Changeset{valid?: true} = _changeset -> + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form( + MyApp.Forms.PasswordForm.changeset_validate(%MyApp.Forms.PasswordForm{}, %{}), + as: :password_input_strict + ) + )} + + changeset -> + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form(changeset, action: :insert, as: :password_input_strict) + )} + end + end + """ + end + + def form_changeset_heex, do: form_doc_controller_changeset_heex() + def form_changeset_elixir, do: form_doc_controller_changeset_elixir() + def form_validate_heex, do: form_doc_controller_validate_heex() + def form_validate_elixir, do: form_doc_controller_validate_elixir() + def form_native_heex, do: form_doc_native_heex() + + attr(:form, :any, required: true) + + def form_preview_controller_changeset(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/password-input/form"} + method="post" + id={@form.id} + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.password_input field={f[:password]} class="password-input" id="password-input-changeset-field"> + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + + <.action + type="submit" + id="password-input-changeset-submit" + class="button button--accent w-full" + > + Submit + + + """ + end + + attr(:form, :any, required: true) + + def form_preview_controller_validate(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/password-input/form"} + method="post" + id={@form.id} + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.password_input field={f[:password]} class="password-input" id="password-input-validate-field"> + <:label>Password (stricter validation) + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + + <.action + type="submit" + id="password-input-validate-submit" + class="button button--accent w-full" + > + Submit + + + """ + end + + def form_preview_controller_native(assigns) do + _ = assigns + + ~H""" +
+ + + + <.action + type="submit" + id="password-input-controller-submit" + class="button button--accent w-full" + > + Submit + +
+ """ + end + + def form_preview_live_changeset(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.password_input + field={@form[:password]} + class="password-input" + id="password-input-live-changeset" + > + <:label>Password + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + + <.action type="submit" id="password-input-form-live-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_preview_live_validate(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate_strict" + phx-submit="save_strict" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.password_input field={@form[:password]} class="password-input" id="password-input-live-strict"> + <:label>Password (stricter validation) + <:visible_indicator><.heroicon name="hero-eye" class="icon" /> + <:hidden_indicator><.heroicon name="hero-eye-slash" class="icon" /> + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + + <.action + type="submit" + id="password-input-form-live-strict-submit" + class="button button--accent w-full" + > + Submit + + + """ + end +end diff --git a/e2e/lib/e2e_web/demos/pin_input_demo.ex b/e2e/lib/e2e_web/demos/pin_input_demo.ex new file mode 100644 index 00000000..bd4ebcc8 --- /dev/null +++ b/e2e/lib/e2e_web/demos/pin_input_demo.ex @@ -0,0 +1,390 @@ +defmodule E2eWeb.Demos.PinInputDemo do + use E2eWeb, :html + + def minimal_code do + ~S""" + <.pin_input id="pin-input-anatomy-minimal" count={4} class="pin-input"> + <:label>Code + + """ + end + + def minimal_example(assigns) do + ~H""" + <.pin_input id="pin-input-anatomy-minimal" count={4} class="pin-input"> + <:label>Code + + """ + end + + def with_default_code do + ~S""" + <.pin_input + id="pin-input-anatomy-default" + count={4} + class="pin-input" + value={["1", "2", "3", "4"]} + > + <:label>Code + + """ + end + + def with_default_example(assigns) do + ~H""" + <.pin_input + id="pin-input-anatomy-default" + count={4} + class="pin-input" + value={["1", "2", "3", "4"]} + > + <:label>Code + + """ + end + + def events_server_heex do + ~S""" + <.pin_input + id="pin-input-events-server" + count={4} + class="pin-input" + on_value_change="pin_input_changed" + > + <:label>Code + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("pin_input_changed", %{"id" => id, "value" => value}, socket) do + log = %{time: "12:00:00", source: "server", value: inspect(value)} + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + ~S""" + <.pin_input + id="pin-input-events-client" + count={4} + class="pin-input" + on_value_change_client="pin-input-changed" + > + <:label>Code + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("pin-input-events-client"); + el?.addEventListener("pin-input-changed", (event) => console.log(event.detail)); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById("pin-input-events-client"); + el?.addEventListener("pin-input-changed", (event: Event) => + console.log((event as CustomEvent).detail) + ); + """ + end + + def form_code do + ~S""" +
+ + <.pin_input + name="pin_input_form[pin]" + id="pin-input-form-pin" + count={4} + class="pin-input" + > + <:label>Code + + <.action type="submit" id="pin-input-form-submit" class="button button--accent"> + Submit + +
+ """ + end + + def api_set_value_client_binding_code do + ~S""" + <.action phx-click={Corex.PinInput.set_value("pin-api-set-client", ["1", "2", "3", "4"])} class="button button--sm"> + Fill + + <.pin_input id="pin-api-set-client" count={4} class="pin-input"> + <:label>Code + + """ + end + + def api_set_value_server_heex do + ~S""" + <.action phx-click="api_pin_set_value_server" class="button button--sm"> + Fill from server + + <.pin_input id="pin-api-set-server" count={4} class="pin-input"> + <:label>Code + + """ + end + + def api_set_value_server_elixir do + ~S""" + def handle_event("api_pin_set_value_server", _params, socket) do + {:noreply, + Corex.PinInput.set_value(socket, "pin-api-set-server", ["1", "2", "3", "4"])} + end + """ + end + + def api_set_value_js_heex do + ~S""" + <.action + phx-click={JS.dispatch("corex:pin-input:set-value", + to: "#pin-api-set-js", + detail: %{value: ["1", "2", "3", "4"]}, + bubbles: false + )} + class="button button--sm" + > + Fill via dispatch + + <.pin_input id="pin-api-set-js" count={4} class="pin-input"> + <:label>Code + + """ + end + + def api_set_value_js_js do + ~S""" + const el = document.getElementById("pin-api-set-js"); + el?.dispatchEvent( + new CustomEvent("corex:pin-input:set-value", { + bubbles: false, + detail: { value: ["1", "2", "3", "4"] }, + }) + ); + """ + end + + def api_set_value_js_ts do + ~S""" + const el = document.getElementById("pin-api-set-js"); + el?.dispatchEvent( + new CustomEvent("corex:pin-input:set-value", { + bubbles: false, + detail: { value: ["1", "2", "3", "4"] }, + }) + ); + """ + end + + def api_set_value_client_js_example(assigns) do + ~H""" +
+ <.action + phx-click={ + JS.dispatch("corex:pin-input:set-value", + to: "##{@id}", + detail: %{value: ["1", "2", "3", "4"]}, + bubbles: false + ) + } + class="button button--sm" + > + Fill via dispatch + +
+ <.pin_input id={@id} count={4} class="pin-input"> + <:label>Code + + """ + end + + def api_set_value_server_example(assigns) do + ~H""" +
+ <.action phx-click="api_pin_set_value_server" class="button button--sm"> + Fill from server + +
+ <.pin_input id={@id} count={4} class="pin-input"> + <:label>Code + + """ + end + + def api_value_client_binding_code do + ~S""" + <.action phx-click={Corex.PinInput.value("pin-api-val-client")} class="button button--sm"> + Read value + + <.pin_input id="pin-api-val-client" count={4} class="pin-input" value={["1", "2", "", ""]}> + <:label>Code + + """ + end + + def api_value_server_heex do + ~S""" + <.action phx-click="api_pin_value_server" class="button button--sm"> + Read value (server) + + <.pin_input id="pin-api-val-server" count={4} class="pin-input" value={["5", "6", "7", "8"]}> + <:label>Code + + """ + end + + def api_value_server_elixir do + ~S""" + def handle_event("api_pin_value_server", _params, socket) do + {:noreply, Corex.PinInput.value(socket, "pin-api-val-server")} + end + """ + end + + def api_value_client_js_heex do + ~S""" + <.action + phx-click={JS.dispatch("corex:pin-input:value", to: "#pin-api-val-js", detail: %{}, bubbles: false)} + class="button button--sm" + > + Read via dispatch + + <.pin_input id="pin-api-val-js" count={4} class="pin-input" value={["1", "0", "0", "0"]}> + <:label>Code + + """ + end + + def api_value_client_js_js do + ~S""" + const el = document.getElementById("pin-api-val-js"); + el?.dispatchEvent(new CustomEvent("corex:pin-input:value", { bubbles: false, detail: {} })); + """ + end + + def api_value_client_js_ts do + ~S""" + const el = document.getElementById("pin-api-val-js"); + el?.dispatchEvent(new CustomEvent("corex:pin-input:value", { bubbles: false, detail: {} })); + """ + end + + def api_value_client_js_example(assigns) do + ~H""" +
+ <.action + phx-click={JS.dispatch("corex:pin-input:value", to: "##{@id}", detail: %{}, bubbles: false)} + class="button button--sm" + > + Read via dispatch + +
+ <.pin_input id={@id} count={4} class="pin-input" value={["1", "0", "0", "0"]}> + <:label>Code + + """ + end + + def api_clear_client_binding_code do + ~S""" + <.action phx-click={Corex.PinInput.clear("pin-api-clear-client")} class="button button--sm"> + Clear + + <.pin_input id="pin-api-clear-client" count={4} class="pin-input" value={["9", "9", "9", "9"]}> + <:label>Code + + """ + end + + def api_clear_server_heex do + ~S""" + <.action phx-click="api_pin_clear_server" class="button button--sm"> + Clear from server + + <.pin_input id="pin-api-clear-server" count={4} class="pin-input" value={["1", "1", "1", "1"]}> + <:label>Code + + """ + end + + def api_clear_server_elixir do + ~S""" + def handle_event("api_pin_clear_server", _params, socket) do + {:noreply, Corex.PinInput.clear(socket, "pin-api-clear-server")} + end + """ + end + + def api_set_value_client_binding_example(assigns) do + ~H""" +
+ <.action + phx-click={Corex.PinInput.set_value(@id, ["1", "2", "3", "4"])} + class="button button--sm" + > + Fill + +
+ <.pin_input id={@id} count={4} class="pin-input"> + <:label>Code + + """ + end + + def api_value_client_binding_example(assigns) do + ~H""" +
+ <.action phx-click={Corex.PinInput.value(@id)} class="button button--sm">Value + <.action phx-click={Corex.PinInput.value(@id, respond_to: :client)} class="button button--sm"> + Value (client only) + +
+ <.pin_input id={@id} count={4} class="pin-input" value={["1", "2", "", ""]}> + <:label>Code + + """ + end + + def api_clear_client_binding_example(assigns) do + ~H""" +
+ <.action phx-click={Corex.PinInput.clear(@id)} class="button button--sm">Clear +
+ <.pin_input id={@id} count={4} class="pin-input" value={["9", "9", "9", "9"]}> + <:label>Code + + """ + end + + def api_value_server_example(assigns) do + ~H""" +
+ <.action phx-click="api_pin_value_server" class="button button--sm">Read from server +
+ <.pin_input id={@id} count={4} class="pin-input" value={["5", "6", "7", "8"]}> + <:label>Code + + """ + end + + def api_clear_server_example(assigns) do + ~H""" +
+ <.action phx-click="api_pin_clear_server" class="button button--sm">Clear from server +
+ <.pin_input id={@id} count={4} class="pin-input" value={["1", "1", "1", "1"]}> + <:label>Code + + """ + end +end diff --git a/e2e/lib/e2e_web/demos/radio_group_demo.ex b/e2e/lib/e2e_web/demos/radio_group_demo.ex new file mode 100644 index 00000000..027815a5 --- /dev/null +++ b/e2e/lib/e2e_web/demos/radio_group_demo.ex @@ -0,0 +1,917 @@ +defmodule E2eWeb.Demos.RadioGroupDemo do + use E2eWeb, :html + + defp items do + [ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"}, + %{value: "c", label: "Option C"} + ] + end + + def minimal_code do + ~S""" + <.radio_group + id="radio-group-anatomy-minimal" + name="rg-minimal" + class="radio-group" + items={[ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"}, + %{value: "c", label: "Option C"} + ]} + > + <:label>Choose one + + """ + end + + def minimal_example(assigns) do + ~H""" + <.radio_group + id="radio-group-anatomy-minimal" + name="rg-minimal" + class="radio-group" + items={items()} + > + <:label>Choose one + + """ + end + + def indicator_code do + ~S""" + <.radio_group + id="radio-group-anatomy-indicator" + name="rg-indicator" + class="radio-group" + items={[ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"}, + %{value: "c", label: "Option C"} + ]} + > + <:label>Choose one + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + """ + end + + def indicator_example(assigns) do + ~H""" + <.radio_group + id="radio-group-anatomy-indicator" + name="rg-indicator" + class="radio-group" + items={items()} + > + <:label>Choose one + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + """ + end + + def api_binding_heex do + ~S""" + <.radio_group + id="radio-group-api-binding" + name="rg-api-binding" + class="radio-group" + items={[ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"}, + %{value: "c", label: "Option C"} + ]} + on_value_change="radio_group_api_binding" + > + <:label>Pick + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + """ + end + + def api_binding_elixir do + ~S""" + def handle_event("radio_group_api_binding", %{"id" => id, "value" => value}, socket) do + {:noreply, socket} + end + """ + end + + def api_binding_example(assigns) do + ~H""" + <.radio_group + id="radio-group-api-binding" + name="rg-api-binding" + class="radio-group" + items={items()} + on_value_change="radio_group_api_binding" + > + <:label>Pick + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + """ + end + + def api_client_heex do + ~S""" + <.radio_group + id="radio-group-api-client" + name="rg-api-client" + class="radio-group" + items={[ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"}, + %{value: "c", label: "Option C"} + ]} + on_value_change_client="radio-group-api-changed" + > + <:label>Pick + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + """ + end + + def api_client_js do + ~S""" + const el = document.getElementById("radio-group-api-client"); + el?.addEventListener("radio-group-api-changed", (event) => console.log(event.detail)); + """ + end + + def api_client_ts do + ~S""" + const el = document.getElementById("radio-group-api-client"); + el?.addEventListener("radio-group-api-changed", (event: Event) => { + console.log((event as CustomEvent<{ id?: string; value?: string | null }>).detail); + }); + """ + end + + def api_client_example(assigns) do + ~H""" + <.radio_group + id="radio-group-api-client" + name="rg-api-client" + class="radio-group" + items={items()} + on_value_change_client="radio-group-api-changed" + > + <:label>Pick + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + """ + end + + def api_controlled_heex do + ~S""" + <.radio_group + id="radio-group-api-controlled" + name="rg-api-controlled" + class="radio-group" + items={[ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"}, + %{value: "c", label: "Option C"} + ]} + value={@value} + controlled + on_value_change="radio_group_api_controlled" + > + <:label>Pick + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + """ + end + + def api_controlled_elixir do + ~S""" + # With controlled={true}, pass value={...} and update it from handle_event. + # The hook reapplies value on LiveView patches (see updated() in the RadioGroup hook). + def handle_event("radio_group_api_controlled", %{"value" => v}, socket) do + {:noreply, assign(socket, :value, v)} + end + """ + end + + def api_controlled_example(assigns) do + ~H""" + <.radio_group + id="radio-group-api-controlled" + name="rg-api-controlled" + class="radio-group" + items={items()} + value={@value} + controlled + on_value_change="radio_group_api_controlled" + > + <:label>Pick + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + """ + end + + def api_overview_code, do: api_binding_heex() + + def api_overview_example(assigns), do: api_binding_example(assigns) + + def events_server_heex do + ~S""" + <.radio_group + id="radio-group-events-server" + name="rg-events-server" + class="radio-group" + items={[ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"} + ]} + on_value_change="radio_group_changed" + > + <:label>Pick + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("radio_group_changed", %{"id" => id, "value" => value}, socket) do + log = %{time: "12:00:00", source: "server", value: inspect(value)} + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + ~S""" + <.radio_group + id="radio-group-events-client" + name="rg-events-client" + class="radio-group" + items={[ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"} + ]} + on_value_change_client="radio-group-changed" + > + <:label>Pick + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("radio-group-events-client"); + el?.addEventListener("radio-group-changed", (event) => console.log(event.detail)); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById("radio-group-events-client"); + el?.addEventListener("radio-group-changed", (event: Event) => + console.log((event as CustomEvent).detail) + ); + """ + end + + def patterns_controlled_heex do + ~S""" + <.radio_group + id="patterns-radio-group-controlled" + name="patterns-rg" + class="radio-group" + items={[ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"}, + %{value: "c", label: "Option C"} + ]} + value={@value} + controlled + on_value_change="patterns_radio_value" + > + <:label>Choose one + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + """ + end + + def patterns_controlled_elixir do + ~S""" + def mount(_params, _session, socket) do + {:ok, assign(socket, :value, "a")} + end + + def handle_event("patterns_radio_value", %{"value" => v}, socket) do + {:noreply, assign(socket, :value, v)} + end + """ + end + + def form_ecto do + ~S""" + defmodule MyApp.Forms.RadioChoiceForm do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :choice, :string + end + + def changeset(form, attrs \\ %{}) do + form + |> cast(attrs, [:choice]) + |> validate_required(:choice) + end + + def changeset_validate(form, attrs \\ %{}) do + form + |> cast(attrs, [:choice]) + |> validate_required([:choice], message: "can't be blank") + end + end + """ + end + + def form_doc_controller_changeset_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/account/choice"} + method="post" + id={@form.id} + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.radio_group + id={Phoenix.HTML.Form.input_id(f, :choice)} + name={Phoenix.HTML.Form.input_name(f, :choice)} + value={to_string(Phoenix.HTML.Form.input_value(f, :choice) || "")} + invalid={f[:choice].errors != []} + class="radio-group" + items={[ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"}, + %{value: "c", label: "Option C"} + ]} + > + <:label>Choose one + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + + <.action type="submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_controller_changeset_elixir do + ~S""" + def account_choice_page(conn, _params) do + changeset = MyApp.Forms.RadioChoiceForm.changeset(%MyApp.Forms.RadioChoiceForm{}, %{}) + + form = + Phoenix.Component.to_form(changeset, + as: :radio_group_changeset, + id: "account-choice-changeset-form" + ) + + render(conn, :account_choice, form: form) + end + + def account_choice_create(conn, %{"radio_group_changeset" => params}) do + case MyApp.Forms.RadioChoiceForm.changeset(%MyApp.Forms.RadioChoiceForm{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + data = Ecto.Changeset.apply_changes(changeset) + conn + |> put_flash(:info, "Saved: choice=#{data.choice}") + |> redirect(to: ~p"/account") + + changeset -> + changeset = Map.put(changeset, :action, :insert) + + form = + Phoenix.Component.to_form(changeset, + as: :radio_group_changeset, + id: "account-choice-changeset-form" + ) + + render(conn, :account_choice, form: form) + end + end + """ + end + + def form_doc_controller_validate_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/account/choice-strict"} + method="post" + id={@form.id} + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.radio_group + id={Phoenix.HTML.Form.input_id(f, :choice)} + name={Phoenix.HTML.Form.input_name(f, :choice)} + value={to_string(Phoenix.HTML.Form.input_value(f, :choice) || "")} + invalid={f[:choice].errors != []} + class="radio-group" + items={[ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"}, + %{value: "c", label: "Option C"} + ]} + > + <:label>Choose one (stricter messages) + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + + <.action type="submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_controller_validate_elixir do + ~S""" + def account_choice_strict_page(conn, _params) do + changeset = + MyApp.Forms.RadioChoiceForm.changeset_validate(%MyApp.Forms.RadioChoiceForm{}, %{}) + + form = + Phoenix.Component.to_form(changeset, + as: :radio_group_validate, + id: "account-choice-validate-form" + ) + + render(conn, :account_choice_strict, form: form) + end + + def account_choice_strict_create(conn, %{"radio_group_validate" => params}) do + case MyApp.Forms.RadioChoiceForm.changeset_validate(%MyApp.Forms.RadioChoiceForm{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + data = Ecto.Changeset.apply_changes(changeset) + conn + |> put_flash(:info, "Saved: choice=#{data.choice}") + |> redirect(to: ~p"/account") + + changeset -> + changeset = Map.put(changeset, :action, :insert) + + form = + Phoenix.Component.to_form(changeset, + as: :radio_group_validate, + id: "account-choice-validate-form" + ) + + render(conn, :account_choice_strict, form: form) + end + end + """ + end + + def form_doc_native_heex do + ~S""" +
+ +
+ Choose one + + + +
+ <.action type="submit" class="button button--accent w-full">Submit +
+ """ + end + + def form_doc_live_changeset_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.radio_group + id="radio-group-live-changeset" + name={@form[:choice].name} + value={to_string(Phoenix.HTML.Form.input_value(@form, :choice) || "")} + controlled + invalid={@form[:choice].errors != []} + class="radio-group" + items={[ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"}, + %{value: "c", label: "Option C"} + ]} + on_value_change="choice_changed" + > + <:label>Choose one + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + + <.action type="submit" id="radio-group-form-live-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_live_changeset_elixir do + ~S""" + def mount(_params, _session, socket) do + form = + %MyApp.Forms.RadioChoiceForm{} + |> MyApp.Forms.RadioChoiceForm.changeset(%{}) + |> Phoenix.Component.to_form(as: :radio_group_live, id: "radio-group-live-form") + + {:ok, assign(socket, :form, form)} + end + + def handle_event("choice_changed", %{"value" => v}, socket) do + params = %{"choice" => v} + + changeset = + %MyApp.Forms.RadioChoiceForm{} + |> MyApp.Forms.RadioChoiceForm.changeset(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, + action: :validate, + as: :radio_group_live, + id: "radio-group-live-form" + ) + )} + end + + def handle_event("validate", %{"radio_group_live" => params}, socket) do + changeset = + %MyApp.Forms.RadioChoiceForm{} + |> MyApp.Forms.RadioChoiceForm.changeset(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, + action: :validate, + as: :radio_group_live, + id: "radio-group-live-form" + ) + )} + end + + def handle_event("save", %{"radio_group_live" => params}, socket) do + case MyApp.Forms.RadioChoiceForm.changeset(%MyApp.Forms.RadioChoiceForm{}, params) do + %Ecto.Changeset{valid?: true} -> + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form( + MyApp.Forms.RadioChoiceForm.changeset(%MyApp.Forms.RadioChoiceForm{}, %{}), + as: :radio_group_live, + id: "radio-group-live-form" + ) + )} + + changeset -> + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, + action: :insert, + as: :radio_group_live, + id: "radio-group-live-form" + ) + )} + end + end + """ + end + + def form_doc_live_validate_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate_strict" + phx-submit="save_strict" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.radio_group + id="radio-group-live-strict" + name={@form[:choice].name} + value={to_string(Phoenix.HTML.Form.input_value(@form, :choice) || "")} + controlled + invalid={@form[:choice].errors != []} + class="radio-group" + items={[ + %{value: "a", label: "Option A"}, + %{value: "b", label: "Option B"}, + %{value: "c", label: "Option C"} + ]} + on_value_change="choice_changed_strict" + > + <:label>Choose one (stricter validation) + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + + <.action type="submit" id="radio-group-form-live-strict-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_live_validate_elixir do + ~S""" + def mount(_params, _session, socket) do + form = + %MyApp.Forms.RadioChoiceForm{} + |> MyApp.Forms.RadioChoiceForm.changeset_validate(%{}) + |> Phoenix.Component.to_form(as: :radio_group_strict, id: "radio-group-strict-form-live") + + {:ok, assign(socket, :strict_form, form)} + end + + def handle_event("choice_changed_strict", %{"value" => v}, socket) do + params = %{"choice" => v} + + changeset = + %MyApp.Forms.RadioChoiceForm{} + |> MyApp.Forms.RadioChoiceForm.changeset_validate(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form(changeset, + action: :validate, + as: :radio_group_strict, + id: "radio-group-strict-form-live" + ) + )} + end + + def handle_event("validate_strict", %{"radio_group_strict" => params}, socket) do + changeset = + %MyApp.Forms.RadioChoiceForm{} + |> MyApp.Forms.RadioChoiceForm.changeset_validate(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form(changeset, + action: :validate, + as: :radio_group_strict, + id: "radio-group-strict-form-live" + ) + )} + end + + def handle_event("save_strict", %{"radio_group_strict" => params}, socket) do + case MyApp.Forms.RadioChoiceForm.changeset_validate(%MyApp.Forms.RadioChoiceForm{}, params) do + %Ecto.Changeset{valid?: true} -> + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form( + MyApp.Forms.RadioChoiceForm.changeset_validate(%MyApp.Forms.RadioChoiceForm{}, %{}), + as: :radio_group_strict, + id: "radio-group-strict-form-live" + ) + )} + + changeset -> + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form(changeset, + action: :insert, + as: :radio_group_strict, + id: "radio-group-strict-form-live" + ) + )} + end + end + """ + end + + def form_changeset_heex, do: form_doc_controller_changeset_heex() + def form_changeset_elixir, do: form_doc_controller_changeset_elixir() + def form_validate_heex, do: form_doc_controller_validate_heex() + def form_validate_elixir, do: form_doc_controller_validate_elixir() + def form_native_heex, do: form_doc_native_heex() + + attr(:form, :any, required: true) + + def form_preview_controller_changeset(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/radio-group/form"} + method="post" + id={@form.id} + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.radio_group + id={Phoenix.HTML.Form.input_id(f, :choice)} + name={Phoenix.HTML.Form.input_name(f, :choice)} + value={to_string(Phoenix.HTML.Form.input_value(f, :choice) || "")} + invalid={f[:choice].errors != []} + class="radio-group" + items={items()} + > + <:label>Choose one + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + + <.action + type="submit" + id="radio-group-changeset-submit" + class="button button--accent w-full" + > + Submit + + + """ + end + + attr(:form, :any, required: true) + + def form_preview_controller_validate(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/radio-group/form"} + method="post" + id={@form.id} + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.radio_group + id={Phoenix.HTML.Form.input_id(f, :choice)} + name={Phoenix.HTML.Form.input_name(f, :choice)} + value={to_string(Phoenix.HTML.Form.input_value(f, :choice) || "")} + invalid={f[:choice].errors != []} + class="radio-group" + items={items()} + > + <:label>Choose one (stricter messages) + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + + <.action + type="submit" + id="radio-group-validate-submit" + class="button button--accent w-full" + > + Submit + + + """ + end + + def form_preview_controller_native(assigns) do + _ = assigns + + ~H""" +
+ +
+ Choose one + + + +
+ <.action type="submit" id="radio-group-controller-submit" class="button button--accent w-full"> + Submit + +
+ """ + end + + attr(:form, :any, required: true) + + def form_preview_live_changeset(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.radio_group + id="radio-group-live-changeset" + name={@form[:choice].name} + value={choice_value(@form)} + controlled + invalid={@form[:choice].errors != []} + class="radio-group" + items={items()} + on_value_change="choice_changed" + > + <:label>Choose one + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + + <.action type="submit" id="radio-group-form-live-submit" class="button button--accent w-full"> + Submit + + + """ + end + + attr(:form, :any, required: true) + + def form_preview_live_validate(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate_strict" + phx-submit="save_strict" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.radio_group + id="radio-group-live-strict" + name={@form[:choice].name} + value={choice_value(@form)} + controlled + invalid={@form[:choice].errors != []} + class="radio-group" + items={items()} + on_value_change="choice_changed_strict" + > + <:label>Choose one (stricter validation) + <:item_control><.heroicon name="hero-check" class="data-checked" /> + + + <.action + type="submit" + id="radio-group-form-live-strict-submit" + class="button button--accent w-full" + > + Submit + + + """ + end + + defp choice_value(form) do + v = + form.params["choice"] || + Ecto.Changeset.get_change(form.source, :choice) || + Ecto.Changeset.get_field(form.source, :choice) + + if v in [nil, ""], do: "", else: to_string(v) + end +end diff --git a/e2e/lib/e2e_web/demos/select_demo.ex b/e2e/lib/e2e_web/demos/select_demo.ex new file mode 100644 index 00000000..f17ab50e --- /dev/null +++ b/e2e/lib/e2e_web/demos/select_demo.ex @@ -0,0 +1,1524 @@ +defmodule E2eWeb.Demos.SelectDemo do + use E2eWeb, :html + + defp items do + Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"} + ]) + end + + defp grouped_items do + Corex.List.new([ + %{label: "France", id: "fra", group: "Europe"}, + %{label: "Belgium", id: "bel", group: "Europe"}, + %{label: "Germany", id: "deu", group: "Europe"}, + %{label: "Japan", id: "jpn", group: "Asia"}, + %{label: "China", id: "chn", group: "Asia"}, + %{label: "South Korea", id: "kor", group: "Asia"} + ]) + end + + defp items_extended do + Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ]) + end + + def minimal_code do + ~S""" + <.select + id="select-anatomy-minimal" + class="select" + items={Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"} + ])} + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def minimal_example(assigns) do + ~H""" + <.select + id="select-anatomy-minimal" + class="select" + items={items()} + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def with_translation_code do + ~S""" + <.select + id="select-anatomy-translation" + class="select" + items={Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"} + ])} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def with_translation_example(assigns) do + ~H""" + <.select + id="select-anatomy-translation" + class="select" + items={items()} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def item_indicator_code do + ~S""" + <.select + id="select-anatomy-item-indicator" + class="select" + items={Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"} + ])} + > + <:label>Country + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:item_indicator><.heroicon name="hero-check" class="icon" /> + + """ + end + + def item_indicator_example(assigns) do + ~H""" + <.select + id="select-anatomy-item-indicator" + class="select" + items={items()} + > + <:label>Country + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:item_indicator><.heroicon name="hero-check" class="icon" /> + + """ + end + + def grouped_code do + ~S""" + <.select + id="select-anatomy-grouped" + class="select" + items={Corex.List.new([ + %{label: "France", id: "fra", group: "Europe"}, + %{label: "Belgium", id: "bel", group: "Europe"}, + %{label: "Germany", id: "deu", group: "Europe"}, + %{label: "Japan", id: "jpn", group: "Asia"}, + %{label: "China", id: "chn", group: "Asia"}, + %{label: "South Korea", id: "kor", group: "Asia"} + ])} + > + <:label>Country + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def grouped_example(assigns) do + ~H""" + <.select + id="select-anatomy-grouped" + class="select" + items={grouped_items()} + > + <:label>Country + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def extended_code do + ~S""" + <.select + id="select-anatomy-extended" + class="select" + items={Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ])} + > + <:label>Country of residence + <:item :let={item}> + + {item.label} + + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:item_indicator><.heroicon name="hero-check" class="icon" /> + + """ + end + + def extended_example(assigns) do + ~H""" + <.select + id="select-anatomy-extended" + class="select" + items={items_extended()} + > + <:label>Country of residence + <:item :let={item}> + + {item.label} + + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:item_indicator><.heroicon name="hero-check" class="icon" /> + + """ + end + + def extended_grouped_code do + ~S""" + <.select + id="select-anatomy-extended-grouped" + class="select" + items={Corex.List.new([ + %{label: "France", id: "fra", group: "Europe"}, + %{label: "Belgium", id: "bel", group: "Europe"}, + %{label: "Germany", id: "deu", group: "Europe"}, + %{label: "Japan", id: "jpn", group: "Asia"}, + %{label: "China", id: "chn", group: "Asia"}, + %{label: "South Korea", id: "kor", group: "Asia"} + ])} + > + <:label>Country of residence + <:item :let={item}> + + {item.label} + + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:item_indicator><.heroicon name="hero-check" class="icon" /> + + """ + end + + def extended_grouped_example(assigns) do + ~H""" + <.select + id="select-anatomy-extended-grouped" + class="select" + items={grouped_items()} + > + <:label>Country of residence + <:item :let={item}> + + {item.label} + + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + <:item_indicator><.heroicon name="hero-check" class="icon" /> + + """ + end + + def styling_color_code do + items_attr = + ~S|items={Corex.List.new([%{label: "France", id: "fra"}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"}])}| + + """ + <.select class="select" #{items_attr}> + <:label>Default + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + <.select class="select select--accent" #{items_attr}> + <:label>Accent + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + <.select class="select select--brand" #{items_attr}> + <:label>Brand + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + <.select class="select select--alert" #{items_attr}> + <:label>Alert + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + <.select class="select select--info" #{items_attr}> + <:label>Info + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + <.select class="select select--success" #{items_attr}> + <:label>Success + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def styling_color_example(assigns) do + ~H""" +
+ <.select + id="select-style-color-default" + class="select" + items={items()} + > + <:label>Default + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + <.select + id="select-style-color-accent" + class="select select--accent" + items={items()} + > + <:label>Accent + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + <.select + id="select-style-color-brand" + class="select select--brand" + items={items()} + > + <:label>Brand + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + <.select + id="select-style-color-alert" + class="select select--alert" + items={items()} + > + <:label>Alert + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + <.select + id="select-style-color-info" + class="select select--info" + items={items()} + > + <:label>Info + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + <.select + id="select-style-color-success" + class="select select--success" + items={items()} + > + <:label>Success + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + +
+ """ + end + + def styling_size_code do + items_attr = + ~S|items={Corex.List.new([%{label: "France", id: "fra"}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"}])}| + + """ + <.select id="select-style-sm" class="select select--sm" #{items_attr}> + <:trigger><.heroicon name="hero-chevron-down" /> + + <.select id="select-style-lg" class="select select--lg" #{items_attr}> + <:trigger><.heroicon name="hero-chevron-down" /> + + """ + end + + def styling_size_example(assigns) do + ~H""" +
+ <.select + id="select-style-sm" + class="select select--sm" + items={items()} + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + <.select + id="select-style-lg" + class="select select--lg" + items={items()} + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + +
+ """ + end + + defp select_items_attr do + ~S|items={Corex.List.new([%{label: "France", id: "fra"}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"}])}| + end + + def api_on_value_server_heex do + items_attr = select_items_attr() + + """ + <.select + id="select-api-on-server" + class="select" + #{items_attr} + on_value_change="select_api_on_value_server" + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def api_on_value_server_elixir do + ~S""" + def handle_event("select_api_on_value_server", %{"id" => _id, "value" => _value}, socket) do + {:noreply, socket} + end + """ + end + + def api_on_value_server_example(assigns) do + ~H""" + <.select + id="select-api-on-server" + class="select" + items={items()} + on_value_change="select_api_on_value_server" + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def api_on_value_client_heex do + items_attr = select_items_attr() + + """ + <.select + id="select-api-on-client" + class="select" + #{items_attr} + on_value_change_client="select-api-on-client" + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def api_on_value_client_js do + ~S""" + const el = document.getElementById("select-api-on-client"); + el?.addEventListener("select-api-on-client", (event) => console.log(event.detail)); + """ + end + + def api_on_value_client_ts do + ~S""" + const el = document.getElementById("select-api-on-client"); + el?.addEventListener("select-api-on-client", (event: Event) => + console.log((event as CustomEvent).detail) + ); + """ + end + + def api_on_value_client_example(assigns) do + ~H""" + <.select + id="select-api-on-client" + class="select" + items={items()} + on_value_change_client="select-api-on-client" + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def api_set_value_client_binding_heex do + items_attr = select_items_attr() + + """ +
+ <.action phx-click={Corex.Select.set_value("select-api-cb", ["fra"])} class="button button--sm"> + France + + <.action phx-click={Corex.Select.set_value("select-api-cb", ["deu"])} class="button button--sm"> + Germany + + <.action phx-click={Corex.Select.set_open("select-api-cb", true)} class="button button--sm"> + Open + +
+ <.select + id="select-api-cb" + class="select" + #{items_attr} + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def api_set_value_client_binding_example(assigns) do + ~H""" +
+
+ <.action + phx-click={Corex.Select.set_value("select-api-cb", ["fra"])} + class="button button--sm" + > + France + + <.action + phx-click={Corex.Select.set_value("select-api-cb", ["deu"])} + class="button button--sm" + > + Germany + + <.action phx-click={Corex.Select.set_open("select-api-cb", true)} class="button button--sm"> + Open + +
+ <.select + id="select-api-cb" + class="select" + items={items()} + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + +
+ """ + end + + def api_set_value_client_js_heex do + items_attr = select_items_attr() + + """ +
+ +
+ <.select + id="select-api-cjs" + class="select" + #{items_attr} + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def api_set_value_client_js_js do + ~S""" + const el = document.getElementById("select-api-cjs"); + el?.dispatchEvent( + new CustomEvent("corex:select:set-value", { bubbles: false, detail: { value: ["fra"] } }) + ); + """ + end + + def api_set_value_client_js_ts do + ~S""" + const el: HTMLElement | null = document.getElementById("select-api-cjs"); + el?.dispatchEvent( + new CustomEvent("corex:select:set-value", { bubbles: false, detail: { value: ["fra"] } }) + ); + """ + end + + def api_set_value_client_js_example(assigns) do + ~H""" +
+
+ +
+ <.select + id="select-api-cjs" + class="select" + items={items()} + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + +
+ """ + end + + def api_set_value_server_heex do + items_attr = select_items_attr() + + """ +
+ <.action phx-click={JS.push("select_api_server_set", value: %{value: "fra"})} class="button button--sm"> + France + + <.action phx-click={JS.push("select_api_server_set", value: %{value: ""})} class="button button--sm"> + Clear + +
+ <.select + id="select-api-srv" + class="select" + #{items_attr} + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def api_set_value_server_elixir do + ~S""" + def handle_event("select_api_server_set", %{"value" => ""}, socket) do + {:noreply, Corex.Select.set_value(socket, "select-api-srv", [])} + end + + def handle_event("select_api_server_set", %{"value" => v}, socket) when is_binary(v) do + {:noreply, Corex.Select.set_value(socket, "select-api-srv", [v])} + end + """ + end + + def api_set_value_server_example(assigns) do + ~H""" +
+
+ <.action + phx-click={JS.push("select_api_server_set", value: %{value: "fra"})} + class="button button--sm" + > + France + + <.action + phx-click={JS.push("select_api_server_set", value: %{value: ""})} + class="button button--sm" + > + Clear + +
+ <.select + id="select-api-srv" + class="select" + items={items()} + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + +
+ """ + end + + def api_codes do + %{ + on_value_server_heex: api_on_value_server_heex(), + on_value_server_elixir: api_on_value_server_elixir(), + on_value_client_heex: api_on_value_client_heex(), + on_value_client_js: api_on_value_client_js(), + on_value_client_ts: api_on_value_client_ts(), + set_value_client_binding: api_set_value_client_binding_heex(), + set_value_client_js_heex: api_set_value_client_js_heex(), + set_value_client_js: api_set_value_client_js_js(), + set_value_client_ts: api_set_value_client_js_ts(), + set_value_server_heex: api_set_value_server_heex(), + set_value_server_elixir: api_set_value_server_elixir() + } + end + + def api_overview_code, do: api_on_value_server_heex() + def api_overview_example(assigns), do: api_on_value_server_example(assigns) + + def events_server_heex do + items_attr = + ~S|items={Corex.List.new([%{label: "France", id: "fra"}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"}])}| + + """ + <.select + id="select-events-server" + class="select" + #{items_attr} + on_value_change="select_changed" + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("select_changed", %{"id" => id, "value" => value}, socket) do + log = %{time: "12:00:00", source: "server", value: inspect(value)} + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + items_attr = + ~S|items={Corex.List.new([%{label: "France", id: "fra"}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"}])}| + + """ + <.select + id="select-events-client" + class="select" + #{items_attr} + on_value_change_client="select-changed" + > + <:trigger><.heroicon name="hero-chevron-down" class="icon" /> + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("select-events-client"); + el?.addEventListener("select-changed", (event) => console.log(event.detail)); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById("select-events-client"); + el?.addEventListener("select-changed", (event: Event) => + console.log((event as CustomEvent).detail) + ); + """ + end + + def form_code, do: form_changeset_heex() + + def form_country_items do + Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ]) + end + + def form_ecto do + ~S""" + defmodule MyApp.Forms.CountryForm do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :country, :string + end + + def changeset(form, attrs \\ %{}) do + form + |> cast(attrs, [:country]) + |> validate_required([:country]) + end + + def changeset_validate(form, attrs \\ %{}) do + form + |> cast(attrs, [:country]) + |> validate_required([:country], message: "can't be blank") + end + end + """ + end + + def form_changeset_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/select/form"} + method="post" + id={@form.id} + > + <.select + field={f[:country]} + class="select" + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + items={Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ])} + > + <:label>Country + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="select-changeset-submit" class="button button--accent"> + Submit + + + """ + end + + def form_changeset_elixir do + ~S""" + def form_page(conn, _params) do + form = + %MyApp.Forms.CountryForm{} + |> MyApp.Forms.CountryForm.changeset(%{}) + |> Phoenix.Component.to_form(as: :select_changeset, id: "select-changeset-form") + + render(conn, :form_page, form: form) + end + """ + end + + def form_validate_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/select/form"} + method="post" + id={@form.id} + > + <.select + field={f[:country]} + class="select" + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + items={Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ])} + > + <:label>Country (stricter messages) + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="select-validate-submit" class="button button--accent"> + Submit + + + """ + end + + def form_validate_elixir do + ~S""" + def form_page(conn, _params) do + form = + %MyApp.Forms.CountryForm{} + |> MyApp.Forms.CountryForm.changeset_validate(%{}) + |> Phoenix.Component.to_form(as: :select_validate, id: "select-validate-form") + + render(conn, :form_page, form: form) + end + """ + end + + def form_native_heex do + ~S""" +
+ + + + <.action type="submit" id="select-controller-submit" class="button button--accent w-full"> + Submit + +
+ """ + end + + def form_doc_live_changeset_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.select + id="select-form-live-country" + class="select" + field={@form[:country]} + items={Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ])} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + on_value_change="select_country_changed" + > + <:label>Country + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="select-form-live-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_live_validate_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate_strict" + phx-submit="save_strict" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.select + id="select-form-live-strict" + class="select" + field={@form[:country]} + items={Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ])} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + on_value_change="select_country_changed_strict" + > + <:label>Country (stricter) + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="select-form-live-strict-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_live_changeset_elixir do + ~S""" + def mount(_params, _session, socket) do + form = + %MyApp.Forms.CountryForm{} + |> MyApp.Forms.CountryForm.changeset(%{}) + |> Phoenix.Component.to_form(as: :select_form, id: "select-form") + + {:ok, assign(socket, :form, form)} + end + + def handle_event("select_country_changed", %{"value" => value}, socket) do + country = List.first(value) || "" + params = %{"country" => country} + + changeset = + %MyApp.Forms.CountryForm{} + |> MyApp.Forms.CountryForm.changeset(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, + action: :validate, + as: :select_form, + id: "select-form" + ) + )} + end + + def handle_event("validate", %{"select_form" => params}, socket) do + changeset = + %MyApp.Forms.CountryForm{} + |> MyApp.Forms.CountryForm.changeset(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, + action: :validate, + as: :select_form, + id: "select-form" + ) + )} + end + + def handle_event("save", %{"select_form" => params}, socket) do + case MyApp.Forms.CountryForm.changeset(%MyApp.Forms.CountryForm{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + _data = Ecto.Changeset.apply_changes(changeset) + {:noreply, assign(socket, :form, Phoenix.Component.to_form( + MyApp.Forms.CountryForm.changeset(%MyApp.Forms.CountryForm{}, %{}), + as: :select_form, id: "select-form" + ))} + + %Ecto.Changeset{} = changeset -> + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, action: :insert, as: :select_form, id: "select-form") + )} + end + end + """ + end + + def form_doc_live_validate_elixir do + ~S""" + def mount(_params, _session, socket) do + form = + %MyApp.Forms.CountryForm{} + |> MyApp.Forms.CountryForm.changeset_validate(%{}) + |> Phoenix.Component.to_form(as: :select_strict, id: "select-strict-form-live") + + {:ok, assign(socket, :strict_form, form)} + end + + def handle_event("select_country_changed_strict", %{"value" => value}, socket) do + country = List.first(value) || "" + params = %{"country" => country} + + changeset = + %MyApp.Forms.CountryForm{} + |> MyApp.Forms.CountryForm.changeset_validate(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form(changeset, + action: :validate, + as: :select_strict, + id: "select-strict-form-live" + ) + )} + end + + def handle_event("validate_strict", %{"select_strict" => params}, socket) do + changeset = + %MyApp.Forms.CountryForm{} + |> MyApp.Forms.CountryForm.changeset_validate(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form(changeset, + action: :validate, + as: :select_strict, + id: "select-strict-form-live" + ) + )} + end + + def handle_event("save_strict", %{"select_strict" => params}, socket) do + case MyApp.Forms.CountryForm.changeset_validate(%MyApp.Forms.CountryForm{}, params) do + %Ecto.Changeset{valid?: true} = changeset -> + _data = Ecto.Changeset.apply_changes(changeset) + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form( + MyApp.Forms.CountryForm.changeset_validate(%MyApp.Forms.CountryForm{}, %{}), + as: :select_strict, + id: "select-strict-form-live" + ) + )} + + %Ecto.Changeset{} = changeset -> + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form(changeset, action: :insert, as: :select_strict, id: "select-strict-form-live") + )} + end + end + """ + end + + attr(:form, :any, required: true) + + def form_preview_controller_changeset(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/select/form"} + method="post" + class="w-full max-w-2xs flex flex-col gap-space items-center" + id={@form.id} + > + <.select + field={f[:country]} + class="select" + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + items={form_country_items()} + invalid={f[:country].errors != []} + > + <:label>Country + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="select-changeset-submit" class="button button--accent w-full"> + Submit + + + """ + end + + attr(:form, :any, required: true) + + def form_preview_controller_validate(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/select/form"} + method="post" + class="w-full max-w-2xs flex flex-col gap-space items-center" + id={@form.id} + > + <.select + field={f[:country]} + class="select" + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + items={form_country_items()} + invalid={f[:country].errors != []} + > + <:label>Country (stricter messages) + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="select-validate-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_preview_controller_native(assigns) do + _ = assigns + + ~H""" +
+ + + + <.action type="submit" id="select-controller-submit" class="button button--accent w-full"> + Submit + +
+ """ + end + + attr(:form, :any, required: true) + + def form_preview_live_changeset(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.select + id="select-form-live-country" + class="select" + field={@form[:country]} + items={form_country_items()} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + on_value_change="select_country_changed" + invalid={@form[:country].errors != []} + > + <:label>Country + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="select-form-live-submit" class="button button--accent w-full"> + Submit + + + """ + end + + attr(:form, :any, required: true) + + def form_preview_live_validate(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate_strict" + phx-submit="save_strict" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.select + id="select-form-live-strict" + class="select" + field={@form[:country]} + items={form_country_items()} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + on_value_change="select_country_changed_strict" + invalid={@form[:country].errors != []} + > + <:label>Country (stricter) + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="select-form-live-strict-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def patterns_items_flat do + Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ]) + end + + def patterns_items_grouped do + Corex.List.new([ + %{label: "France", id: "fra", group: "Europe"}, + %{label: "Belgium", id: "bel", group: "Europe"}, + %{label: "Germany", id: "deu", group: "Europe"}, + %{label: "Japan", id: "jpn", group: "Asia"}, + %{label: "China", id: "chn", group: "Asia"}, + %{label: "South Korea", id: "kor", group: "Asia"}, + %{label: "Thailand", id: "tha", group: "Asia"}, + %{label: "USA", id: "usa", group: "North America"}, + %{label: "Canada", id: "can", group: "North America"}, + %{label: "Mexico", id: "mex", group: "North America"} + ]) + end + + def patterns_controlled_heex do + ~S""" + <.select + id="select-patterns-controlled" + class="select" + controlled + value={@value} + items={@items} + on_value_change="value_changed" + > + <:label>Country + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + + """ + end + + def patterns_controlled_elixir do + ~S""" + def mount(_params, _session, socket) do + items = + Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ]) + + {:ok, assign(socket, value: [], items: items)} + end + + def handle_event("value_changed", %{"value" => value}, socket) do + {:noreply, assign(socket, :value, value)} + end + """ + end + + def patterns_flat_example(assigns) do + ~H""" + <.select + id="select-patterns-flat" + class="select" + items={patterns_items_flat()} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + > + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + + """ + end + + def patterns_grouped_example(assigns) do + ~H""" + <.select + id="select-patterns-grouped" + class="select" + items={patterns_items_grouped()} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + > + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + + """ + end + + def patterns_extended_example(assigns) do + ~H""" + <.select + id="select-patterns-extended" + class="select" + items={patterns_items_flat()} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + > + <:label>Country of residence + <:item :let={item}> + + {item.label} + + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:item_indicator> + <.heroicon name="hero-check" class="icon" /> + + + """ + end + + def patterns_extended_grouped_example(assigns) do + ~H""" + <.select + id="select-patterns-extended-grouped" + class="select" + items={patterns_items_grouped()} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + > + <:label>Country of residence + <:item :let={item}> + + {item.label} + + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:item_indicator> + <.heroicon name="hero-check" class="icon" /> + + + """ + end + + def patterns_flat_code do + ~S""" + <.select + id="select-patterns-flat" + class="select" + items={Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ])} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + > + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + + """ + end + + def patterns_grouped_code do + ~S""" + <.select + id="select-patterns-grouped" + class="select" + items={Corex.List.new([ + %{label: "France", id: "fra", group: "Europe"}, + %{label: "Belgium", id: "bel", group: "Europe"}, + %{label: "Germany", id: "deu", group: "Europe"}, + %{label: "Japan", id: "jpn", group: "Asia"}, + %{label: "China", id: "chn", group: "Asia"}, + %{label: "South Korea", id: "kor", group: "Asia"}, + %{label: "Thailand", id: "tha", group: "Asia"}, + %{label: "USA", id: "usa", group: "North America"}, + %{label: "Canada", id: "can", group: "North America"}, + %{label: "Mexico", id: "mex", group: "North America"} + ])} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + > + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + + """ + end + + def patterns_extended_code do + ~S""" + <.select + id="select-patterns-extended" + class="select" + items={Corex.List.new([ + %{label: "France", id: "fra"}, + %{label: "Belgium", id: "bel"}, + %{label: "Germany", id: "deu"}, + %{label: "Netherlands", id: "nld"}, + %{label: "Switzerland", id: "che"}, + %{label: "Austria", id: "aut"} + ])} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + > + <:label>Country of residence + <:item :let={item}> + + {item.label} + + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:item_indicator> + <.heroicon name="hero-check" class="icon" /> + + + """ + end + + def patterns_extended_grouped_code do + ~S""" + <.select + id="select-patterns-extended-grouped" + class="select" + items={Corex.List.new([ + %{label: "France", id: "fra", group: "Europe"}, + %{label: "Belgium", id: "bel", group: "Europe"}, + %{label: "Germany", id: "deu", group: "Europe"}, + %{label: "Japan", id: "jpn", group: "Asia"}, + %{label: "China", id: "chn", group: "Asia"}, + %{label: "South Korea", id: "kor", group: "Asia"} + ])} + translation={%Corex.Select.Translation{placeholder: "Select a country"}} + > + <:label>Country of residence + <:item :let={item}> + + {item.label} + + <:trigger> + <.heroicon name="hero-chevron-down" class="icon" /> + + <:item_indicator> + <.heroicon name="hero-check" class="icon" /> + + + """ + end +end diff --git a/e2e/lib/e2e_web/demos/signature_demo.ex b/e2e/lib/e2e_web/demos/signature_demo.ex new file mode 100644 index 00000000..79aa98f6 --- /dev/null +++ b/e2e/lib/e2e_web/demos/signature_demo.ex @@ -0,0 +1,403 @@ +defmodule E2eWeb.Demos.SignatureDemo do + use E2eWeb, :html + + def minimal_code do + ~S""" + <.signature_pad id="signature-anatomy-minimal" class="signature-pad"> + <:clear_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def minimal_example(assigns) do + _ = assigns + + ~H""" + <.signature_pad id="signature-anatomy-minimal" class="signature-pad"> + <:clear_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def with_label_code do + ~S""" + <.signature_pad id="signature-anatomy-labeled" class="signature-pad"> + <:label>Sign here + <:clear_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def with_label_example(assigns) do + _ = assigns + + ~H""" + <.signature_pad id="signature-anatomy-labeled" class="signature-pad"> + <:label>Sign here + <:clear_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def api_clear_client_binding_heex do + ~S""" +
+ <.action phx-click={Corex.SignaturePad.clear("signature-api-cb")} class="button button--sm"> + Clear + +
+ + <.signature_pad id="signature-api-cb" class="signature-pad"> + <:label>Sign here + <:clear_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def api_clear_client_binding_example(assigns) do + _ = assigns + + ~H""" +
+ <.action phx-click={Corex.SignaturePad.clear("signature-api-cb")} class="button button--sm"> + Clear + +
+ + <.signature_pad id="signature-api-cb" class="signature-pad"> + <:label>Sign here + <:clear_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def api_clear_client_js_heex do + ~S""" +
+ +
+ + <.signature_pad id="signature-api-cjs" class="signature-pad"> + <:label>Sign here + <:clear_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def api_clear_client_js_js do + ~S""" + const el = document.getElementById("signature-api-cjs"); + el?.dispatchEvent( + new CustomEvent("corex:signature-pad:clear", { + bubbles: false, + detail: { id: "signature-api-cjs" } + }) + ); + """ + end + + def api_clear_client_js_ts do + ~S""" + const el: HTMLElement | null = document.getElementById("signature-api-cjs"); + el?.dispatchEvent( + new CustomEvent("corex:signature-pad:clear", { + bubbles: false, + detail: { id: "signature-api-cjs" } + }) + ); + """ + end + + def api_clear_client_js_example(assigns) do + _ = assigns + + ~H""" +
+
+ +
+ <.signature_pad id="signature-api-cjs" class="signature-pad"> + <:label>Sign here + <:clear_trigger><.heroicon name="hero-x-mark" /> + +
+ """ + end + + def api_clear_server_heex do + ~S""" +
+ <.action phx-click="signature_api_clear" class="button button--sm"> + Clear (server) + +
+ + <.signature_pad id="signature-api-srv" class="signature-pad"> + <:label>Sign here + <:clear_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def api_clear_server_elixir do + ~S""" + def handle_event("signature_api_clear", _params, socket) do + {:noreply, Corex.SignaturePad.clear(socket, "signature-api-srv")} + end + """ + end + + def api_clear_server_example(assigns) do + _ = assigns + + ~H""" +
+
+ <.action phx-click="signature_api_clear" class="button button--sm"> + Clear (server) + +
+ <.signature_pad id="signature-api-srv" class="signature-pad"> + <:label>Sign here + <:clear_trigger><.heroicon name="hero-x-mark" /> + +
+ """ + end + + def api_client_binding_code, do: api_clear_client_binding_heex() + def api_client_binding_example(assigns), do: api_clear_client_binding_example(assigns) + + def api_codes do + %{ + clear_client_binding: api_clear_client_binding_heex(), + clear_client_js_heex: api_clear_client_js_heex(), + clear_client_js: api_clear_client_js_js(), + clear_client_ts: api_clear_client_js_ts(), + clear_server_heex: api_clear_server_heex(), + clear_server_elixir: api_clear_server_elixir() + } + end + + def events_server_heex do + ~S""" + <.signature_pad id="signature-events-server" class="signature-pad" on_draw_end="signature_drawn"> + <:label>Sign here + <:clear_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("signature_drawn", %{"id" => id, "url" => url}, socket) do + log = %{time: "12:00:00", source: "server", value: inspect(String.slice(url, 0, 32))} + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + ~S""" + <.signature_pad + id="signature-events-client" + class="signature-pad" + on_draw_end_client="signature-drawn" + > + <:label>Sign here + <:clear_trigger><.heroicon name="hero-x-mark" /> + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("signature-events-client"); + el?.addEventListener("signature-drawn", (event) => console.log(event.detail)); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById("signature-events-client"); + el?.addEventListener("signature-drawn", (event: Event) => + console.log((event as CustomEvent).detail) + ); + """ + end + + def form_code, do: form_changeset_heex() + + def form_ecto do + ~S""" + defmodule MyApp.Forms.SignatureForm do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :signature, :string + end + + def changeset(form, attrs \\ %{}) do + form + |> cast(attrs, [:signature]) + |> validate_required([:signature]) + end + + def changeset_validate(form, attrs \\ %{}) do + form + |> cast(attrs, [:signature]) + |> validate_required([:signature], message: "can't be blank") + end + end + """ + end + + def form_changeset_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/signature/form"} + method="post" + id={@form.id} + > + <.signature_pad field={f[:signature]}> + <:label>Sign here + <:clear_trigger> + <.heroicon name="hero-x-mark" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="signature-changeset-submit" class="button button--accent"> + Submit + + + """ + end + + def form_changeset_elixir do + ~S""" + def form_page(conn, _params) do + form = + %MyApp.Forms.SignatureForm{} + |> MyApp.Forms.SignatureForm.changeset(%{}) + |> Phoenix.Component.to_form(as: :signature_changeset, id: "signature-changeset-form") + + render(conn, :form_page, form: form) + end + """ + end + + def form_validate_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/signature/form"} + method="post" + id={@form.id} + > + <.signature_pad field={f[:signature]}> + <:label>Sign here (stricter) + <:clear_trigger> + <.heroicon name="hero-x-mark" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="signature-validate-submit" class="button button--accent"> + Submit + + + """ + end + + def form_validate_elixir do + ~S""" + def form_page(conn, _params) do + form = + %MyApp.Forms.SignatureForm{} + |> MyApp.Forms.SignatureForm.changeset_validate(%{}) + |> Phoenix.Component.to_form(as: :signature_validate, id: "signature-validate-form") + + render(conn, :form_page, form: form) + end + """ + end + + attr(:form, :any, required: true) + + def form_preview_controller_changeset(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/signature/form"} + method="post" + class="w-full max-w-2xs flex flex-col gap-space items-center" + id={@form.id} + > + <.signature_pad field={f[:signature]}> + <:label>Sign here + <:clear_trigger> + <.heroicon name="hero-x-mark" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="signature-changeset-submit" class="button button--accent w-full"> + Submit + + + """ + end + + attr(:form, :any, required: true) + + def form_preview_controller_validate(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/signature/form"} + method="post" + class="w-full max-w-2xs flex flex-col gap-space items-center" + id={@form.id} + > + <.signature_pad field={f[:signature]}> + <:label>Sign here (stricter) + <:clear_trigger> + <.heroicon name="hero-x-mark" /> + + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="signature-validate-submit" class="button button--accent w-full"> + Submit + + + """ + end +end diff --git a/e2e/lib/e2e_web/demos/switch_demo.ex b/e2e/lib/e2e_web/demos/switch_demo.ex new file mode 100644 index 00000000..b1117739 --- /dev/null +++ b/e2e/lib/e2e_web/demos/switch_demo.ex @@ -0,0 +1,763 @@ +defmodule E2eWeb.Demos.SwitchDemo do + use E2eWeb, :html + + def minimal_code do + ~S""" + <.switch id="switch-anatomy-minimal" class="switch"> + <:label>Enable + + """ + end + + def minimal_example(assigns) do + ~H""" + <.switch id="switch-anatomy-minimal" class="switch"> + <:label>Enable + + """ + end + + def patterns_controlled_heex do + ~S""" + <.switch + id="switch-patterns-controlled" + class="switch" + controlled + checked={@checked} + on_checked_change="patterns_checked" + > + <:label>Enable + + """ + end + + def patterns_controlled_elixir do + ~S""" + def mount(_params, _session, socket) do + {:ok, assign(socket, :checked, false)} + end + + def handle_event("patterns_checked", %{"checked" => checked}, socket) do + {:noreply, assign(socket, :checked, checked == true or checked == "true")} + end + """ + end + + def styling_size_code do + ~S""" + <.switch id="switch-style-sm" class="switch switch--sm" checked> + <:label>SM + + <.switch id="switch-style-md" class="switch switch--md" checked> + <:label>MD + + <.switch id="switch-style-lg" class="switch switch--lg" checked> + <:label>LG + + <.switch id="switch-style-xl" class="switch switch--xl" checked> + <:label>XL + + """ + end + + def styling_size_example(assigns) do + _ = assigns + + ~H""" +
+ <.switch id="switch-style-sm" class="switch switch--sm" checked> + <:label>SM + + <.switch id="switch-style-md" class="switch switch--md" checked> + <:label>MD + + <.switch id="switch-style-lg" class="switch switch--lg" checked> + <:label>LG + + <.switch id="switch-style-xl" class="switch switch--xl" checked> + <:label>XL + +
+ """ + end + + def styling_color_code do + ~S""" + <.switch id="switch-style-c-default" class="switch" checked> + <:label>Default + + <.switch id="switch-style-c-accent" class="switch switch--accent" checked> + <:label>Accent + + <.switch id="switch-style-c-brand" class="switch switch--brand" checked> + <:label>Brand + + <.switch id="switch-style-c-alert" class="switch switch--alert" checked> + <:label>Alert + + <.switch id="switch-style-c-info" class="switch switch--info" checked> + <:label>Info + + <.switch id="switch-style-c-success" class="switch switch--success" checked> + <:label>Success + + """ + end + + def styling_color_example(assigns) do + _ = assigns + + ~H""" +
+ <.switch id="switch-style-c-default" class="switch" checked> + <:label>Default + + <.switch id="switch-style-c-accent" class="switch switch--accent" checked> + <:label>Accent + + <.switch id="switch-style-c-brand" class="switch switch--brand" checked> + <:label>Brand + + <.switch id="switch-style-c-alert" class="switch switch--alert" checked> + <:label>Alert + + <.switch id="switch-style-c-info" class="switch switch--info" checked> + <:label>Info + + <.switch id="switch-style-c-success" class="switch switch--success" checked> + <:label>Success + +
+ """ + end + + def api_set_checked_client_binding_heex do + ~S""" +
+ <.action phx-click={Corex.Switch.set_checked("switch-api-cb", true)} class="button button--sm">On + <.action phx-click={Corex.Switch.set_checked("switch-api-cb", false)} class="button button--sm">Off +
+ <.switch id="switch-api-cb" class="switch"> + <:label>Power + + """ + end + + def api_set_checked_client_binding_example(assigns) do + _ = assigns + + ~H""" +
+ <.action phx-click={Corex.Switch.set_checked("switch-api-cb", true)} class="button button--sm"> + On + + <.action phx-click={Corex.Switch.set_checked("switch-api-cb", false)} class="button button--sm"> + Off + +
+ <.switch id="switch-api-cb" class="switch"> + <:label>Power + + """ + end + + def api_set_checked_client_js_heex do + ~S""" +
+ +
+ <.switch id="switch-api-cjs" class="switch"> + <:label>Power + + """ + end + + def api_set_checked_client_js_js do + ~S""" + const el = document.getElementById("switch-api-cjs"); + el?.dispatchEvent( + new CustomEvent("corex:switch:set-checked", { bubbles: false, detail: { checked: true } }) + ); + """ + end + + def api_set_checked_client_js_ts do + ~S""" + const el: HTMLElement | null = document.getElementById("switch-api-cjs"); + el?.dispatchEvent( + new CustomEvent("corex:switch:set-checked", { bubbles: false, detail: { checked: true } }) + ); + """ + end + + def api_set_checked_client_js_example(assigns) do + _ = assigns + + ~H""" +
+
+ +
+ <.switch id="switch-api-cjs" class="switch"> + <:label>Power + +
+ """ + end + + def api_set_checked_server_heex do + ~S""" +
+ <.action phx-click="switch_api_on" class="button button--sm">On + <.action phx-click="switch_api_off" class="button button--sm">Off +
+ <.switch id="switch-api-srv" class="switch"> + <:label>Power + + """ + end + + def api_set_checked_server_elixir do + ~S""" + def handle_event("switch_api_on", _params, socket) do + {:noreply, Corex.Switch.set_checked(socket, "switch-api-srv", true)} + end + + def handle_event("switch_api_off", _params, socket) do + {:noreply, Corex.Switch.set_checked(socket, "switch-api-srv", false)} + end + """ + end + + def api_set_checked_server_example(assigns) do + _ = assigns + + ~H""" +
+
+ <.action phx-click="switch_api_on" class="button button--sm">On + <.action phx-click="switch_api_off" class="button button--sm">Off +
+ <.switch id="switch-api-srv" class="switch"> + <:label>Power + +
+ """ + end + + def api_codes do + %{ + set_checked_client_binding: api_set_checked_client_binding_heex(), + set_checked_client_js_heex: api_set_checked_client_js_heex(), + set_checked_client_js: api_set_checked_client_js_js(), + set_checked_client_ts: api_set_checked_client_js_ts(), + set_checked_server_heex: api_set_checked_server_heex(), + set_checked_server_elixir: api_set_checked_server_elixir() + } + end + + def api_client_binding_code, do: api_set_checked_client_binding_heex() + + def api_client_binding_example(assigns) do + ~H""" +
+ <.action phx-click={Corex.Switch.set_checked(@id, true)} class="button button--sm">On + <.action phx-click={Corex.Switch.set_checked(@id, false)} class="button button--sm"> + Off + +
+ <.switch id={@id} class="switch"> + <:label>Power + + """ + end + + def events_server_heex do + ~S""" + <.switch id="switch-on-checked-change-server" class="switch" on_checked_change="switch_changed"> + <:label>Subscribe + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("switch_changed", %{"id" => id, "checked" => checked}, socket) do + {:noreply, stream_insert(socket, :logs, %{id: id, checked: checked}, at: 0)} + end + """ + end + + def events_client_heex do + ~S""" + <.switch id="switch-on-checked-change-client" class="switch" on_checked_change_client="switch-changed"> + <:label>Subscribe + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("switch-on-checked-change-client"); + el?.addEventListener("switch-changed", (event) => console.log(event.detail)); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById("switch-on-checked-change-client"); + el?.addEventListener("switch-changed", (event: Event) => + console.log((event as CustomEvent<{ id: string; checked: boolean }>).detail) + ); + """ + end + + def patterns_common_code do + ~S""" + <.switch id="switch-pattern" class="switch"> + <:label>Option + + """ + end + + def patterns_common_example(assigns) do + ~H""" + <.switch id="switch-pattern" class="switch"> + <:label>Option + + """ + end + + def form_ecto do + ~S""" + defmodule MyApp.Forms.Preferences do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :notifications, :boolean, default: false + end + + def changeset(form, attrs \\ %{}) do + form + |> cast(attrs, [:notifications]) + |> validate_required([:notifications]) + end + + def changeset_validate(form, attrs \\ %{}) do + form + |> cast(attrs, [:notifications]) + |> validate_required([:notifications], message: "can't be blank") + end + end + """ + end + + def form_changeset_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/switch/form"} + method="post" + id={@form.id} + > + <.switch field={f[:notifications]} class="switch"> + <:label>Enable notifications + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="switch-changeset-submit" class="button button--accent"> + Submit + + + """ + end + + def form_changeset_elixir do + ~S""" + def form_page(conn, _params) do + form = + %E2e.Form.Preferences{} + |> E2e.Form.Preferences.changeset(%{}) + |> Phoenix.Component.to_form(as: :preferences_changeset, id: "switch-changeset-form") + + render(conn, :form_page, form: form) + end + """ + end + + def form_validate_heex do + ~S""" + <.form + :let={f} + for={@form} + action={~p"/switch/form"} + method="post" + id={@form.id} + > + <.switch field={f[:notifications]} class="switch"> + <:label>Enable notifications (stricter) + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="switch-validate-submit" class="button button--accent"> + Submit + + + """ + end + + def form_validate_elixir do + ~S""" + def form_page(conn, _params) do + form = + %E2e.Form.Preferences{} + |> E2e.Form.Preferences.changeset_validate(%{}) + |> Phoenix.Component.to_form(as: :preferences_validate, id: "switch-validate-form") + + render(conn, :form_page, form: form) + end + """ + end + + attr(:form, :any, required: true) + + def form_preview_controller_changeset(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/switch/form"} + method="post" + class="w-full max-w-2xs flex flex-col gap-space items-center" + id={@form.id} + > + <.switch field={f[:notifications]} class="switch" invalid={f[:notifications].errors != []}> + <:label>Enable notifications + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="switch-changeset-submit" class="button button--accent w-full"> + Submit + + + """ + end + + attr(:form, :any, required: true) + + def form_preview_controller_validate(assigns) do + ~H""" + <.form + :let={f} + for={@form} + action={~p"/switch/form"} + method="post" + class="w-full max-w-2xs flex flex-col gap-space items-center" + id={@form.id} + > + <.switch field={f[:notifications]} class="switch" invalid={f[:notifications].errors != []}> + <:label>Enable notifications (stricter) + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="switch-validate-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_native_heex do + ~S""" +
+ + <.switch + name="user[notifications]" + id="switch-form-native" + class="switch" + > + <:label>Enable notifications + + <.action type="submit" id="switch-controller-submit" class="button button--accent w-full"> + Submit + +
+ """ + end + + def form_native_heex, do: form_doc_native_heex() + + def form_doc_live_changeset_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.switch + field={@form[:notifications]} + class="switch" + controlled + id="switch-form-live-notifications" + > + <:label>Enable notifications + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="switch-form-live-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_live_changeset_elixir do + ~S""" + def mount(_params, _session, socket) do + form = + %E2e.Form.Preferences{} + |> E2e.Form.Preferences.changeset(%{}) + |> Phoenix.Component.to_form(as: :preferences, id: "switch-form-live") + + {:ok, assign(socket, :form, form)} + end + + def handle_event("validate", %{"preferences" => params}, socket) do + changeset = + %E2e.Form.Preferences{} + |> E2e.Form.Preferences.changeset(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, action: :validate, as: :preferences, id: "switch-form-live") + )} + end + + def handle_event("save", %{"preferences" => params}, socket) do + case E2e.Form.Preferences.changeset(%E2e.Form.Preferences{}, params) do + %Ecto.Changeset{valid?: true} = _changeset -> + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form( + E2e.Form.Preferences.changeset(%E2e.Form.Preferences{}, %{}), + as: :preferences, + id: "switch-form-live" + ) + )} + + changeset -> + {:noreply, + assign( + socket, + :form, + Phoenix.Component.to_form(changeset, action: :insert, as: :preferences, id: "switch-form-live") + )} + end + end + """ + end + + def form_doc_live_validate_heex do + ~S""" + <.form + for={@form} + id={@form.id} + phx-change="validate_strict" + phx-submit="save_strict" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.switch + field={@form[:notifications]} + class="switch" + controlled + id="switch-form-live-strict" + > + <:label>Enable notifications (stricter) + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="switch-form-live-strict-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_doc_live_validate_elixir do + ~S""" + def mount(_params, _session, socket) do + form = + %E2e.Form.Preferences{} + |> E2e.Form.Preferences.changeset_validate(%{}) + |> Phoenix.Component.to_form(as: :preferences_strict, id: "switch-strict-form-live") + + {:ok, assign(socket, :strict_form, form)} + end + + def handle_event("validate_strict", %{"preferences_strict" => params}, socket) do + changeset = + %E2e.Form.Preferences{} + |> E2e.Form.Preferences.changeset_validate(params) + |> Map.put(:action, :validate) + + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form(changeset, + action: :validate, + as: :preferences_strict, + id: "switch-strict-form-live" + ) + )} + end + + def handle_event("save_strict", %{"preferences_strict" => params}, socket) do + case E2e.Form.Preferences.changeset_validate(%E2e.Form.Preferences{}, params) do + %Ecto.Changeset{valid?: true} = _changeset -> + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form( + E2e.Form.Preferences.changeset_validate(%E2e.Form.Preferences{}, %{}), + as: :preferences_strict, + id: "switch-strict-form-live" + ) + )} + + changeset -> + {:noreply, + assign( + socket, + :strict_form, + Phoenix.Component.to_form(changeset, + action: :insert, + as: :preferences_strict, + id: "switch-strict-form-live" + ) + )} + end + end + """ + end + + def form_preview_live_changeset(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate" + phx-submit="save" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.switch + field={@form[:notifications]} + class="switch" + controlled + id="switch-form-live-notifications" + > + <:label>Enable notifications + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="switch-form-live-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_preview_live_validate(assigns) do + ~H""" + <.form + for={@form} + id={@form.id} + phx-change="validate_strict" + phx-submit="save_strict" + class="w-full max-w-2xs flex flex-col gap-space items-center" + > + <.switch + field={@form[:notifications]} + class="switch" + controlled + id="switch-form-live-strict" + > + <:label>Enable notifications (stricter) + <:error :let={msg}> + <.heroicon name="hero-exclamation-circle" class="icon" /> + {msg} + + + <.action type="submit" id="switch-form-live-strict-submit" class="button button--accent w-full"> + Submit + + + """ + end + + def form_preview_controller_native(assigns) do + _ = assigns + + ~H""" +
+ + <.switch + name="user[notifications]" + id="switch-form-native" + class="switch" + > + <:label>Enable notifications + + <.action type="submit" id="switch-controller-submit" class="button button--accent w-full"> + Submit + +
+ """ + end +end diff --git a/e2e/lib/e2e_web/demos/tabs_demo.ex b/e2e/lib/e2e_web/demos/tabs_demo.ex new file mode 100644 index 00000000..5fed8044 --- /dev/null +++ b/e2e/lib/e2e_web/demos/tabs_demo.ex @@ -0,0 +1,420 @@ +defmodule E2eWeb.Demos.TabsDemo do + use E2eWeb, :html + + def basic_items do + Corex.Content.new([ + %{ + value: "lorem", + trigger: "Lorem", + content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique." + }, + %{ + value: "duis", + trigger: "Duis", + content: "Nullam eget vestibulum ligula, at interdum tellus." + }, + %{ + value: "donec", + trigger: "Donec", + content: "Congue molestie ipsum gravida a. Sed ac eros luctus." + } + ]) + end + + def anatomy_basic_code do + ~S""" + <.tabs id="tabs-basic" class="tabs" value="lorem" items={E2eWeb.Demos.TabsDemo.basic_items()} /> + """ + end + + def anatomy_basic_example(assigns) do + ~H""" + <.tabs id="tabs-basic" class="tabs" value="lorem" items={E2eWeb.Demos.TabsDemo.basic_items()} /> + """ + end + + def anatomy_indicator_code do + ~S""" + <.tabs + id="tabs-indicator" + class="tabs" + indicator + value="lorem" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> + """ + end + + def anatomy_indicator_example(assigns) do + ~H""" + <.tabs + id="tabs-indicator" + class="tabs" + indicator + value="lorem" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> + """ + end + + def anatomy_nested_code do + ~S""" + <.tabs id="tabs-nested-outer" class="tabs" value="outer-2"> + <:trigger value="outer-1">Outer 1 + <:trigger value="outer-2">Outer 2 + + <:content value="outer-1"> + Outer content + + + <:content value="outer-2"> + <.tabs + id="tabs-nested-inner" + class="tabs" + value="lorem" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> + + + """ + end + + def anatomy_nested_example(assigns) do + ~H""" + <.tabs id="tabs-nested-outer" class="tabs" value="outer-2"> + <:trigger value="outer-1">Outer 1 + <:trigger value="outer-2">Outer 2 + + <:content value="outer-1"> +
+

Outer content

+
+ + + <:content value="outer-2"> +
+

Inner tabs

+ <.tabs + id="tabs-nested-inner" + class="tabs" + value="lorem" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> +
+ + + """ + end + + def api_set_value_client_binding_heex do + ~S""" +
+ <.action phx-click={Corex.Tabs.set_value("tabs-api-cb", "lorem")} class="button button--sm">Lorem + <.action phx-click={Corex.Tabs.set_value("tabs-api-cb", "duis")} class="button button--sm">Duis + <.action phx-click={Corex.Tabs.set_value("tabs-api-cb", nil)} class="button button--sm">Close all +
+ <.tabs id="tabs-api-cb" class="tabs w-full" value="lorem" items={E2eWeb.Demos.TabsDemo.basic_items()} /> + """ + end + + def api_set_value_client_binding_example(assigns) do + _ = assigns + + ~H""" +
+
+ <.action phx-click={Corex.Tabs.set_value("tabs-api-cb", "lorem")} class="button button--sm"> + Lorem + + <.action phx-click={Corex.Tabs.set_value("tabs-api-cb", "duis")} class="button button--sm"> + Duis + + <.action phx-click={Corex.Tabs.set_value("tabs-api-cb", nil)} class="button button--sm"> + Close all + +
+ <.tabs + id="tabs-api-cb" + class="tabs w-full" + value="lorem" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> +
+ """ + end + + def api_set_value_client_js_heex do + ~S""" +
+ +
+ <.tabs id="tabs-api-cjs" class="tabs w-full" value="lorem" items={E2eWeb.Demos.TabsDemo.basic_items()} /> + """ + end + + def api_set_value_client_js_js do + ~S""" + const el = document.getElementById("tabs-api-cjs"); + el?.dispatchEvent( + new CustomEvent("corex:tabs:set-value", { bubbles: false, detail: { value: "lorem" } }) + ); + """ + end + + def api_set_value_client_js_ts do + ~S""" + const el: HTMLElement | null = document.getElementById("tabs-api-cjs"); + el?.dispatchEvent( + new CustomEvent("corex:tabs:set-value", { bubbles: false, detail: { value: "lorem" } }) + ); + """ + end + + def api_set_value_client_js_example(assigns) do + _ = assigns + + ~H""" +
+
+ +
+ <.tabs + id="tabs-api-cjs" + class="tabs w-full" + value="lorem" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> +
+ """ + end + + def api_set_value_server_heex do + ~S""" +
+ <.action phx-click="tabs_api_lorem" class="button button--sm">Lorem + <.action phx-click="tabs_api_duis" class="button button--sm">Duis + <.action phx-click="tabs_api_close" class="button button--sm">Close all +
+ <.tabs id="tabs-api-srv" class="tabs w-full" value="lorem" items={E2eWeb.Demos.TabsDemo.basic_items()} /> + """ + end + + def api_set_value_server_elixir do + ~S""" + def handle_event("tabs_api_lorem", _params, socket) do + {:noreply, Corex.Tabs.set_value(socket, "tabs-api-srv", "lorem")} + end + + def handle_event("tabs_api_duis", _params, socket) do + {:noreply, Corex.Tabs.set_value(socket, "tabs-api-srv", "duis")} + end + + def handle_event("tabs_api_close", _params, socket) do + {:noreply, Corex.Tabs.set_value(socket, "tabs-api-srv", nil)} + end + """ + end + + def api_set_value_server_example(assigns) do + _ = assigns + + ~H""" +
+
+ <.action phx-click="tabs_api_lorem" class="button button--sm">Lorem + <.action phx-click="tabs_api_duis" class="button button--sm">Duis + <.action phx-click="tabs_api_close" class="button button--sm">Close all +
+ <.tabs + id="tabs-api-srv" + class="tabs w-full" + value="lorem" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> +
+ """ + end + + def api_codes do + %{ + set_value_client_binding: api_set_value_client_binding_heex(), + set_value_client_js_heex: api_set_value_client_js_heex(), + set_value_client_js: api_set_value_client_js_js(), + set_value_client_ts: api_set_value_client_js_ts(), + set_value_server_heex: api_set_value_server_heex(), + set_value_server_elixir: api_set_value_server_elixir() + } + end + + def api_client_binding_code, do: api_set_value_client_binding_heex() + + def api_client_binding_example(assigns), do: api_set_value_client_binding_example(assigns) + + def patterns_controlled_heex do + ~S""" + <.tabs + id="tabs-patterns-controlled" + class="tabs w-full" + value={@value} + controlled + on_value_change="tabs_pattern_value" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> + """ + end + + def patterns_controlled_elixir do + ~S""" + def mount(_params, _session, socket) do + {:ok, assign(socket, :value, "lorem")} + end + + def handle_event("tabs_pattern_value", %{"value" => value}, socket) do + v = + case value do + nil -> "lorem" + "" -> "lorem" + other -> to_string(other) + end + + {:noreply, assign(socket, :value, v)} + end + """ + end + + attr :value, :any, default: "lorem" + + def patterns_controlled_example(assigns) do + value = if assigns.value in [nil, ""], do: "lorem", else: to_string(assigns.value) + + assigns = assign(assigns, :value, value) + + ~H""" + <.tabs + id="tabs-patterns-controlled" + class="tabs w-full" + value={@value} + controlled + on_value_change="tabs_pattern_value" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> + """ + end + + def styling_color_code do + ~S""" + <.tabs id="tabs-style-baseline" class="tabs" value="lorem" items={E2eWeb.Demos.TabsDemo.basic_items()} /> + <.tabs id="tabs-style-accent" class="tabs tabs--accent" value="lorem" items={E2eWeb.Demos.TabsDemo.basic_items()} /> + """ + end + + def styling_color_example(assigns) do + _ = assigns + + ~H""" +
+ <.tabs + id="tabs-style-baseline" + class="tabs w-full max-w-md" + value="lorem" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> + <.tabs + id="tabs-style-accent" + class="tabs tabs--accent w-full max-w-md" + value="lorem" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> +
+ """ + end + + def styling_spacing_code do + ~S""" + <.tabs id="tabs-style-tight" class="tabs tabs--4" value="lorem" items={E2eWeb.Demos.TabsDemo.basic_items()} /> + <.tabs id="tabs-style-roomy" class="tabs tabs--8" value="lorem" items={E2eWeb.Demos.TabsDemo.basic_items()} /> + """ + end + + def styling_spacing_example(assigns) do + _ = assigns + + ~H""" +
+ <.tabs + id="tabs-style-tight" + class="tabs tabs--4 w-full" + value="lorem" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> + <.tabs + id="tabs-style-roomy" + class="tabs tabs--8 w-full" + value="lorem" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> +
+ """ + end + + def events_server_heex do + ~S""" + <.tabs + id="tabs-events-server" + class="tabs" + value="lorem" + on_value_change="tabs_value_changed" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> + """ + end + + def events_server_elixir do + ~S""" + def handle_event("tabs_value_changed", %{"value" => value, "id" => id}, socket) do + log = %{time: "12:00:00", source: "server", value: inspect(%{id: id, value: value})} + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + ~S""" + <.tabs + id="tabs-events-client" + class="tabs" + value="lorem" + on_value_change_client="tabs-value-changed" + items={E2eWeb.Demos.TabsDemo.basic_items()} + /> + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("tabs-events-client"); + el?.addEventListener("tabs-value-changed", (event) => console.log(event.detail)); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById("tabs-events-client"); + el?.addEventListener("tabs-value-changed", (event: Event) => + console.log((event as CustomEvent).detail) + ); + """ + end +end diff --git a/e2e/lib/e2e_web/demos/timer_demo.ex b/e2e/lib/e2e_web/demos/timer_demo.ex new file mode 100644 index 00000000..d2930cbf --- /dev/null +++ b/e2e/lib/e2e_web/demos/timer_demo.ex @@ -0,0 +1,230 @@ +defmodule E2eWeb.Demos.TimerDemo do + use E2eWeb, :html + + def anatomy_countdown_code do + ~S""" + <.timer id="timer-anatomy" countdown start_ms={60_000} target_ms={0} class="timer"> + <:start_trigger><.heroicon name="hero-play" class="icon" /> + <:pause_trigger><.heroicon name="hero-pause" class="icon" /> + <:resume_trigger><.heroicon name="hero-play" class="icon" /> + <:reset_trigger><.heroicon name="hero-arrow-path" class="icon" /> + + """ + end + + def anatomy_countdown_example(assigns) do + ~H""" + <.timer id="timer-anatomy" countdown start_ms={60_000} target_ms={0} class="timer"> + <:start_trigger><.heroicon name="hero-play" class="icon" /> + <:pause_trigger><.heroicon name="hero-pause" class="icon" /> + <:resume_trigger><.heroicon name="hero-play" class="icon" /> + <:reset_trigger><.heroicon name="hero-arrow-path" class="icon" /> + + """ + end + + def events_combined_heex do + ~S""" + <.timer + id="timer-events-live" + countdown + start_ms={3_600_000} + target_ms={0} + class="timer" + on_tick="timer_tick" + on_tick_client="timer-tick" + on_complete="timer_complete" + on_complete_client="timer-complete" + > + <:start_trigger><.heroicon name="hero-play" class="icon" /> + <:pause_trigger><.heroicon name="hero-pause" class="icon" /> + <:resume_trigger><.heroicon name="hero-play" class="icon" /> + <:reset_trigger><.heroicon name="hero-arrow-path" class="icon" /> + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("timer_tick", %{"id" => id} = params, socket) do + ft = Map.get(params, "formattedTime", "") + _ = {id, ft} + {:noreply, socket} + end + + def handle_event("timer_complete", %{"id" => id}, socket) do + _ = id + {:noreply, socket} + end + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("timer-events-live") + if (!el) return + el.addEventListener("timer-tick", (event) => { + const d = event.detail + console.log(d?.formattedTime, d?.id) + }) + el.addEventListener("timer-complete", (event) => { + console.log(event.detail?.id) + }) + """ + end + + def events_server_heex, do: events_combined_heex() + + def api_template_props_countdown_heex do + ~S""" + <.timer + id="t-countdown" + countdown + start_ms={60_000} + target_ms={0} + class="timer" + > + <:start_trigger>… + <:pause_trigger>… + <:resume_trigger>… + <:reset_trigger>… + + """ + end + + def api_template_props_timing_heex do + ~S""" + <.timer id="t-interval" start_ms={60_000} interval={1000} auto_start class="timer"> + <:start_trigger>… + <:pause_trigger>… + <:resume_trigger>… + <:reset_trigger>… + + """ + end + + def api_template_props_direction_heex do + ~S""" + <.timer id="t-dir" dir="rtl" orientation="vertical" class="timer"> + <:start_trigger>… + <:pause_trigger>… + <:resume_trigger>… + <:reset_trigger>… + + """ + end + + def api_codes_intro do + "Template attributes only—see Timer · Events for on_tick and on_complete." + end + + def patterns_minimal_heex do + ~S""" + <.timer id="timer-pattern" countdown start_ms={60_000} target_ms={0} class="timer"> + <:start_trigger><.heroicon name="hero-play" class="icon" /> + <:pause_trigger><.heroicon name="hero-pause" class="icon" /> + <:resume_trigger><.heroicon name="hero-play" class="icon" /> + <:reset_trigger><.heroicon name="hero-arrow-path" class="icon" /> + + """ + end + + def patterns_minimal_elixir do + ~S""" + # No server state required for an uncontrolled timer. + """ + end + + def patterns_minimal_example(assigns) do + _ = assigns + + ~H""" + <.timer id="timer-patterns-minimal" countdown start_ms={60_000} target_ms={0} class="timer"> + <:start_trigger><.heroicon name="hero-play" class="icon" /> + <:pause_trigger><.heroicon name="hero-pause" class="icon" /> + <:resume_trigger><.heroicon name="hero-play" class="icon" /> + <:reset_trigger><.heroicon name="hero-arrow-path" class="icon" /> + + """ + end + + def styling_size_code do + ~S""" + <.timer id="timer-style-sm" class="timer timer--sm" start_ms={60_000}> + <.timer id="timer-style-lg" class="timer timer--lg" start_ms={60_000}> + """ + end + + def styling_size_example(assigns) do + _ = assigns + + ~H""" +
+ <.timer + id="timer-style-sm" + class="timer timer--sm" + start_ms={60_000} + target_ms={0} + countdown + > + <:start_trigger><.heroicon name="hero-play" class="icon" /> + <:pause_trigger><.heroicon name="hero-pause" class="icon" /> + <:resume_trigger><.heroicon name="hero-play" class="icon" /> + <:reset_trigger><.heroicon name="hero-arrow-path" class="icon" /> + + <.timer + id="timer-style-lg" + class="timer timer--lg" + start_ms={60_000} + target_ms={0} + countdown + > + <:start_trigger><.heroicon name="hero-play" class="icon" /> + <:pause_trigger><.heroicon name="hero-pause" class="icon" /> + <:resume_trigger><.heroicon name="hero-play" class="icon" /> + <:reset_trigger><.heroicon name="hero-arrow-path" class="icon" /> + +
+ """ + end + + def styling_color_code do + ~S""" + <.timer id="timer-c-def" class="timer" start_ms={60_000} /> + <.timer id="timer-c-ac" class="timer timer--accent" start_ms={60_000} /> + """ + end + + def styling_color_example(assigns) do + _ = assigns + + ~H""" +
+ <.timer + id="timer-c-def" + class="timer" + start_ms={60_000} + target_ms={0} + countdown + > + <:start_trigger><.heroicon name="hero-play" class="icon" /> + <:pause_trigger><.heroicon name="hero-pause" class="icon" /> + <:resume_trigger><.heroicon name="hero-play" class="icon" /> + <:reset_trigger><.heroicon name="hero-arrow-path" class="icon" /> + + <.timer + id="timer-c-ac" + class="timer timer--accent" + start_ms={60_000} + target_ms={0} + countdown + > + <:start_trigger><.heroicon name="hero-play" class="icon" /> + <:pause_trigger><.heroicon name="hero-pause" class="icon" /> + <:resume_trigger><.heroicon name="hero-play" class="icon" /> + <:reset_trigger><.heroicon name="hero-arrow-path" class="icon" /> + +
+ """ + end +end diff --git a/e2e/lib/e2e_web/demos/toast_demo.ex b/e2e/lib/e2e_web/demos/toast_demo.ex new file mode 100644 index 00000000..cf92bc19 --- /dev/null +++ b/e2e/lib/e2e_web/demos/toast_demo.ex @@ -0,0 +1,198 @@ +defmodule E2eWeb.Demos.ToastDemo do + use E2eWeb, :html + + def api_client_binding_code do + ~S""" +
+ <.action + phx-click={Corex.Toast.create_toast("layout-toast", "Info", "Info description", :info, [])} + class="button button--sm" + > + Info + + <.action + phx-click={Corex.Toast.create_toast("layout-toast", "Success", "Success description", :success, [])} + class="button button--sm" + > + Success + + <.action + phx-click={Corex.Toast.create_toast("layout-toast", "Error", "Error description", :error, [])} + class="button button--sm" + > + Error + + <.action + phx-click={Corex.Toast.create_toast("layout-toast", "Loading", "Loading description", :loading, duration: :infinity)} + class="button button--sm" + > + Loading + +
+ """ + end + + def api_client_binding_example(assigns) do + ~H""" +
+ <.action + phx-click={Corex.Toast.create_toast("layout-toast", "Info", "Info description", :info, [])} + class="button button--sm" + > + Info + + <.action + phx-click={ + Corex.Toast.create_toast("layout-toast", "Success", "Success description", :success, []) + } + class="button button--sm" + > + Success + + <.action + phx-click={Corex.Toast.create_toast("layout-toast", "Error", "Error description", :error, [])} + class="button button--sm" + > + Error + + <.action + phx-click={ + Corex.Toast.create_toast("layout-toast", "Loading", "Loading description", :loading, + duration: :infinity + ) + } + class="button button--sm" + > + Loading + +
+ """ + end + + def api_create_toast_client_js_heex do + ~S""" + + """ + end + + def api_create_toast_client_js do + ~S""" + const el = document.getElementById("layout-toast"); + el?.dispatchEvent( + new CustomEvent("toast:create", { + bubbles: false, + detail: { + id: "toast-cjs-2", + groupId: "layout-toast", + title: "Info", + description: "From client JS", + type: "info", + duration: "5000", + }, + }) + ); + """ + end + + def api_create_toast_client_ts do + ~S""" + const el: HTMLElement | null = document.getElementById("layout-toast"); + el?.dispatchEvent( + new CustomEvent("toast:create", { + bubbles: false, + detail: { + id: "toast-cjs-2", + groupId: "layout-toast", + title: "Info", + description: "From client JS", + type: "info", + duration: "5000", + }, + }) + ); + """ + end + + def api_create_toast_client_js_example(assigns) do + _ = assigns + + ~H""" +
+ +
+ """ + end + + def api_push_toast_server_heex do + ~S""" + <.action phx-click="toast_api_info" class="button button--sm">Push info + """ + end + + def api_push_toast_server_elixir do + ~S""" + def handle_event("toast_api_info", _params, socket) do + {:noreply, + Corex.Toast.push_toast( + socket, + "layout-toast", + "Saved", + "From server", + :info, + 5000 + )} + end + """ + end + + def api_push_toast_server_example(assigns) do + _ = assigns + + ~H""" +
+ <.action phx-click="toast_api_info" class="button button--sm">Push info +
+ """ + end + + def api_codes do + %{ + create_toast_client_binding: api_client_binding_code(), + create_toast_client_js_heex: api_create_toast_client_js_heex(), + create_toast_client_js: api_create_toast_client_js(), + create_toast_client_ts: api_create_toast_client_ts(), + push_toast_server_heex: api_push_toast_server_heex(), + push_toast_server_elixir: api_push_toast_server_elixir() + } + end + + def patterns_form_code do + ~S""" + <.form for={@form} as={:toast} phx-submit="create_flash" id={@form.id}> + <.native_input field={@form[:title]} type="text" required><:label>Title + <.native_input field={@form[:message]} type="text" required><:label>Message + <.select class="select" field={@form[:type]} items={[...]}> + <:label>Type + <:trigger><.heroicon name="hero-chevron-down" /> + + <.action type="submit" class="button button--accent">Create + + """ + end + + def patterns_client_actions_code do + api_client_binding_code() + end +end diff --git a/e2e/lib/e2e_web/demos/toggle_group_demo.ex b/e2e/lib/e2e_web/demos/toggle_group_demo.ex new file mode 100644 index 00000000..0a483407 --- /dev/null +++ b/e2e/lib/e2e_web/demos/toggle_group_demo.ex @@ -0,0 +1,319 @@ +defmodule E2eWeb.Demos.ToggleGroupDemo do + use E2eWeb, :html + + def anatomy_basic_code do + ~S""" + <.toggle_group id="toggle-group-anatomy" class="toggle-group"> + <:item value="lorem">Lorem + <:item value="duis">Duis + <:item value="donec">Donec + + """ + end + + def anatomy_basic_example(assigns) do + ~H""" + <.toggle_group id="toggle-group-anatomy" class="toggle-group"> + <:item value="lorem">Lorem + <:item value="duis">Duis + <:item value="donec">Donec + + """ + end + + def api_set_value_client_binding_heex do + ~S""" +
+ <.action phx-click={Corex.ToggleGroup.set_value("toggle-group-api-cb", ["lorem"])} class="button button--sm">Lorem + <.action phx-click={Corex.ToggleGroup.set_value("toggle-group-api-cb", ["lorem", "donec"])} class="button button--sm">Lorem+Donec + <.action phx-click={Corex.ToggleGroup.set_value("toggle-group-api-cb", [])} class="button button--sm">Clear +
+ <.toggle_group id="toggle-group-api-cb" class="toggle-group" multiple> + <:item value="lorem">Lorem + <:item value="duis">Duis + <:item value="donec">Donec + + """ + end + + def api_set_value_client_binding_example(assigns) do + _ = assigns + + ~H""" +
+
+ <.action + phx-click={Corex.ToggleGroup.set_value("toggle-group-api-cb", ["lorem"])} + class="button button--sm" + > + Lorem + + <.action + phx-click={Corex.ToggleGroup.set_value("toggle-group-api-cb", ["lorem", "donec"])} + class="button button--sm" + > + Lorem+Donec + + <.action + phx-click={Corex.ToggleGroup.set_value("toggle-group-api-cb", [])} + class="button button--sm" + > + Clear + +
+ <.toggle_group id="toggle-group-api-cb" class="toggle-group" multiple> + <:item value="lorem">Lorem + <:item value="duis">Duis + <:item value="donec">Donec + +
+ """ + end + + def api_set_value_client_js_heex do + ~S""" + + <.toggle_group id="toggle-group-api-cjs" class="toggle-group" multiple> + <:item value="lorem">Lorem + <:item value="duis">Duis + + """ + end + + def api_set_value_client_js_js do + ~S""" + const el = document.getElementById("toggle-group-api-cjs"); + el?.dispatchEvent( + new CustomEvent("corex:toggle-group:set-value", { bubbles: false, detail: { value: ["lorem"] } }) + ); + """ + end + + def api_set_value_client_js_ts do + api_set_value_client_js_js() + end + + def api_set_value_client_js_example(assigns) do + _ = assigns + + ~H""" +
+
+ +
+ <.toggle_group id="toggle-group-api-cjs" class="toggle-group" multiple> + <:item value="lorem">Lorem + <:item value="duis">Duis + +
+ """ + end + + def api_set_value_server_heex do + ~S""" +
+ <.action phx-click="tg_api_lorem" class="button button--sm">Lorem + <.action phx-click="tg_api_clear" class="button button--sm">Clear +
+ <.toggle_group id="toggle-group-api-srv" class="toggle-group" multiple> + <:item value="lorem">Lorem + <:item value="duis">Duis + + """ + end + + def api_set_value_server_elixir do + ~S""" + def handle_event("tg_api_lorem", _params, socket) do + {:noreply, Corex.ToggleGroup.set_value(socket, "toggle-group-api-srv", ["lorem"])} + end + + def handle_event("tg_api_clear", _params, socket) do + {:noreply, Corex.ToggleGroup.set_value(socket, "toggle-group-api-srv", [])} + end + """ + end + + def api_set_value_server_example(assigns) do + _ = assigns + + ~H""" +
+
+ <.action phx-click="tg_api_lorem" class="button button--sm">Lorem + <.action phx-click="tg_api_clear" class="button button--sm">Clear +
+ <.toggle_group id="toggle-group-api-srv" class="toggle-group" multiple> + <:item value="lorem">Lorem + <:item value="duis">Duis + +
+ """ + end + + def api_codes do + %{ + set_value_client_binding: api_set_value_client_binding_heex(), + set_value_client_js_heex: api_set_value_client_js_heex(), + set_value_client_js: api_set_value_client_js_js(), + set_value_client_ts: api_set_value_client_js_ts(), + set_value_server_heex: api_set_value_server_heex(), + set_value_server_elixir: api_set_value_server_elixir() + } + end + + def api_client_binding_code, do: api_set_value_client_binding_heex() + + def api_client_binding_example(assigns), do: api_set_value_client_binding_example(assigns) + + def patterns_controlled_heex do + ~S""" + <.toggle_group + id="toggle-group-patterns-controlled" + class="toggle-group" + value={@value} + multiple + controlled + on_value_change="toggle_group_pattern" + > + <:item value="lorem">Lorem + <:item value="duis">Duis + + """ + end + + def patterns_controlled_elixir do + ~S""" + def mount(_params, _session, socket) do + {:ok, assign(socket, :value, ["lorem"])} + end + + def handle_event("toggle_group_pattern", %{"value" => v}, socket) do + {:noreply, assign(socket, :value, v || [])} + end + """ + end + + def patterns_controlled_example(assigns) do + ~H""" + <.toggle_group + id="toggle-group-patterns-controlled" + class="toggle-group" + value={@value} + multiple + controlled + on_value_change="toggle_group_pattern" + > + <:item value="lorem">Lorem + <:item value="duis">Duis + + """ + end + + def styling_duo_code do + ~S""" + <.toggle_group id="tg-style-duo" class="toggle-group toggle-group--duo" multiple> + <:item value="a">A + <:item value="b">B + + <.toggle_group + id="tg-style-circle" + class="toggle-group toggle-group--circle" + multiple={false} + value={["x"]} + > + <:item value="x">X + <:item value="y">Y + + """ + end + + def styling_duo_example(assigns) do + _ = assigns + + ~H""" +
+ <.toggle_group id="tg-style-duo" class="toggle-group toggle-group--duo" multiple> + <:item value="a">A + <:item value="b">B + + <.toggle_group + id="tg-style-circle" + class="toggle-group toggle-group--circle" + multiple={false} + value={["x"]} + > + <:item value="x">X + <:item value="y">Y + +
+ """ + end + + def events_server_heex do + ~S""" + <.toggle_group + id="toggle-group-events-server" + class="toggle-group" + on_value_change="toggle_group_changed" + multiple + > + <:item value="lorem">Lorem + <:item value="duis">Duis + <:item value="donec">Donec + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("toggle_group_changed", %{"id" => id, "value" => value}, socket) do + log = %{time: "12:00:00", source: "server", value: inspect(value)} + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + ~S""" + <.toggle_group + id="toggle-group-events-client" + class="toggle-group" + on_value_change_client="toggle-group-changed" + multiple + > + <:item value="lorem">Lorem + <:item value="duis">Duis + <:item value="donec">Donec + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("toggle-group-events-client"); + el?.addEventListener("toggle-group-changed", (event) => console.log(event.detail)); + """ + end + + def events_client_ts do + ~S""" + const el = document.getElementById("toggle-group-events-client"); + el?.addEventListener("toggle-group-changed", (event: Event) => + console.log((event as CustomEvent).detail) + ); + """ + end +end diff --git a/e2e/lib/e2e_web/demos/tooltip_demo.ex b/e2e/lib/e2e_web/demos/tooltip_demo.ex new file mode 100644 index 00000000..8f48b9f9 --- /dev/null +++ b/e2e/lib/e2e_web/demos/tooltip_demo.ex @@ -0,0 +1,340 @@ +defmodule E2eWeb.Demos.TooltipDemo do + use E2eWeb, :html + + def anatomy_default_open_code do + ~S""" + <.tooltip id="tooltip-default-open" class="tooltip" open> + <:trigger>Hover or focus (starts open) + <:content>Tooltip content + + """ + end + + def anatomy_default_open_example(assigns) do + ~H""" + <.tooltip id="tooltip-default-open" class="tooltip" open> + <:trigger>Hover or focus (starts open) + <:content>Tooltip content + + """ + end + + def anatomy_placement_code do + ~S""" +
+ <.tooltip class="tooltip" placement="bottom"> + <:trigger>Bottom + <:content>Tooltip below + + <.tooltip class="tooltip" placement="top"> + <:trigger>Top + <:content>Tooltip above + + <.tooltip class="tooltip" placement="left"> + <:trigger>Left + <:content>Tooltip on the left + + <.tooltip class="tooltip" placement="right"> + <:trigger>Right + <:content>Tooltip on the right + +
+ """ + end + + def anatomy_placement_example(assigns) do + ~H""" +
+ <.tooltip class="tooltip" placement="bottom"> + <:trigger>Bottom + <:content>Tooltip below + + <.tooltip class="tooltip" placement="top"> + <:trigger>Top + <:content>Tooltip above + + <.tooltip class="tooltip" placement="left"> + <:trigger>Left + <:content>Tooltip on the left + + <.tooltip class="tooltip" placement="right"> + <:trigger>Right + <:content>Tooltip on the right + +
+ """ + end + + def anatomy_variants_code do + ~S""" +
+ <.tooltip class="tooltip tooltip--sm" show_arrow> + <:trigger>Small + <:content>Small tooltip + + <.tooltip class="tooltip" show_arrow> + <:trigger>Default + <:content>Default tooltip + + <.tooltip class="tooltip tooltip--lg" show_arrow> + <:trigger>Large + <:content>Large tooltip + + <.tooltip class="tooltip tooltip--accent" show_arrow> + <:trigger>Accent + <:content>Accent tooltip + +
+ """ + end + + def anatomy_variants_example(assigns) do + ~H""" +
+ <.tooltip class="tooltip tooltip--sm" show_arrow> + <:trigger>Small + <:content>Small tooltip + + <.tooltip class="tooltip" show_arrow> + <:trigger>Default + <:content>Default tooltip + + <.tooltip class="tooltip tooltip--lg" show_arrow> + <:trigger>Large + <:content>Large tooltip + + <.tooltip class="tooltip tooltip--accent" show_arrow> + <:trigger>Accent + <:content>Accent tooltip + +
+ """ + end + + def api_set_open_client_binding_heex do + ~S""" +
+ <.action phx-click={Corex.Tooltip.set_open("tooltip-api-cb", true)} class="button button--sm">Open + <.action phx-click={Corex.Tooltip.set_open("tooltip-api-cb", false)} class="button button--sm">Close +
+ <.tooltip id="tooltip-api-cb" class="tooltip"> + <:trigger>Hover or focus + <:content>Tooltip content + + """ + end + + def api_set_open_client_binding_example(assigns) do + _ = assigns + + ~H""" +
+
+ <.action phx-click={Corex.Tooltip.set_open("tooltip-api-cb", true)} class="button button--sm"> + Open + + <.action phx-click={Corex.Tooltip.set_open("tooltip-api-cb", false)} class="button button--sm"> + Close + +
+ <.tooltip id="tooltip-api-cb" class="tooltip"> + <:trigger>Hover or focus + <:content>Tooltip content + +
+ """ + end + + def api_set_open_client_js_heex do + ~S""" + + <.tooltip id="tooltip-api-cjs" class="tooltip"> + <:trigger>Target + <:content>Tooltip + + """ + end + + def api_set_open_client_js_js do + ~S""" + const el = document.getElementById("tooltip-api-cjs"); + el?.dispatchEvent( + new CustomEvent("corex:tooltip:set-open", { bubbles: false, detail: { open: true } }) + ); + """ + end + + def api_set_open_client_js_ts do + api_set_open_client_js_js() + end + + def api_set_open_client_js_example(assigns) do + _ = assigns + + ~H""" +
+
+ +
+ <.tooltip id="tooltip-api-cjs" class="tooltip"> + <:trigger>Target + <:content>Tooltip + +
+ """ + end + + def api_set_open_server_heex do + ~S""" +
+ <.action phx-click="tooltip_api_open" class="button button--sm">Open + <.action phx-click="tooltip_api_close" class="button button--sm">Close +
+ <.tooltip id="tooltip-api-srv" class="tooltip"> + <:trigger>Hover or focus + <:content>Tooltip content + + """ + end + + def api_set_open_server_elixir do + ~S""" + def handle_event("tooltip_api_open", _params, socket) do + {:noreply, Corex.Tooltip.set_open(socket, "tooltip-api-srv", true)} + end + + def handle_event("tooltip_api_close", _params, socket) do + {:noreply, Corex.Tooltip.set_open(socket, "tooltip-api-srv", false)} + end + """ + end + + def api_set_open_server_example(assigns) do + _ = assigns + + ~H""" +
+
+ <.action phx-click="tooltip_api_open" class="button button--sm">Open + <.action phx-click="tooltip_api_close" class="button button--sm">Close +
+ <.tooltip id="tooltip-api-srv" class="tooltip"> + <:trigger>Hover or focus + <:content>Tooltip content + +
+ """ + end + + def api_codes do + %{ + set_open_client_binding: api_set_open_client_binding_heex(), + set_open_client_js_heex: api_set_open_client_js_heex(), + set_open_client_js: api_set_open_client_js_js(), + set_open_client_ts: api_set_open_client_js_ts(), + set_open_server_heex: api_set_open_server_heex(), + set_open_server_elixir: api_set_open_server_elixir() + } + end + + def api_client_binding_code, do: api_set_open_client_binding_heex() + + def api_client_binding_example(assigns), do: api_set_open_client_binding_example(assigns) + + def patterns_controlled_heex do + ~S""" + <.tooltip + id="tooltip-patterns-controlled" + class="tooltip" + controlled + open={@open} + on_open_change="tooltip_pattern_open" + > + <:trigger>Controlled + <:content>Synced with assign + + """ + end + + def patterns_controlled_elixir do + ~S""" + def mount(_params, _session, socket) do + {:ok, assign(socket, :open, false)} + end + + def handle_event("tooltip_pattern_open", %{"open" => open, "id" => _}, socket) do + o = open == true or open == "true" + {:noreply, assign(socket, :open, o)} + end + """ + end + + attr :open, :boolean, default: false + + def patterns_controlled_example(assigns) do + ~H""" + <.tooltip + id="tooltip-patterns-controlled" + class="tooltip" + controlled + open={@open} + on_open_change="tooltip_pattern_open" + > + <:trigger>Controlled + <:content>Synced with assign + + """ + end + + def events_server_heex do + ~S""" + <.tooltip + id="tooltip-events" + class="tooltip" + on_open_change="tooltip_open_changed" + on_open_change_client="tooltip-open-changed" + > + <:trigger>Hover me + <:content>Tooltip content + + """ + end + + def events_server_elixir do + ~S""" + def handle_event("tooltip_open_changed", %{"open" => open, "id" => id}, socket) do + _ = {open, id} + {:noreply, socket} + end + """ + end + + def events_client_listener_js do + ~S""" + const el = document.getElementById("tooltip-events"); + el?.addEventListener("tooltip-open-changed", (event) => { + console.log(event.detail); + }); + """ + end + + def styling_size_color_code do + anatomy_variants_code() + end + + def styling_size_color_example(assigns) do + anatomy_variants_example(assigns) + end +end diff --git a/e2e/lib/e2e_web/demos/tree_view_demo.ex b/e2e/lib/e2e_web/demos/tree_view_demo.ex new file mode 100644 index 00000000..169caec9 --- /dev/null +++ b/e2e/lib/e2e_web/demos/tree_view_demo.ex @@ -0,0 +1,1383 @@ +defmodule E2eWeb.Demos.TreeViewDemo do + use E2eWeb, :html + + # -------------------------------------------------------------------------- + # Shared tree data (runtime) + # -------------------------------------------------------------------------- + + @doc """ + Default repo tree used by anatomy and styling previews. + """ + def anatomy_items do + Corex.Tree.new([ + %{ + label: "lib", + id: "lib", + children: [ + %{label: "tree_view.ex", id: "lib-tree-view-ex"}, + %{label: "tree_view_demo.ex", id: "lib-tree-view-demo-ex"} + ] + }, + %{ + label: "test", + id: "test", + children: [ + %{label: "tree_view_test.exs", id: "test-tree-view-test-exs"} + ] + }, + %{ + label: "assets", + id: "assets", + children: [ + %{label: "tree-view.ts", id: "assets-tree-view-ts"} + ] + }, + %{label: "mix.exs", id: "mix-exs"} + ]) + end + + @doc """ + Repo tree used by the API / Events / Animation previews. Uses `repo-*` ids so + the code snippets referenced in buttons match the preview items. + """ + def api_items do + Corex.Tree.new([ + %{ + label: "corex", + id: "repo-corex", + children: [ + %{ + label: "lib", + id: "repo-lib", + children: [ + %{label: "tree_view.ex", id: "repo-lib-tree-view-ex"}, + %{label: "tree_view_demo.ex", id: "repo-lib-tree-view-demo-ex"} + ] + }, + %{label: "mix.exs", id: "repo-mix-exs"} + ] + } + ]) + end + + @doc """ + Styling preview tree (uses `styling-*` ids so styling sections don't collide + with the anatomy tree ids in the E2E DOM). + """ + def styling_items do + Corex.Tree.new([ + %{ + label: "lib", + id: "styling-lib", + children: [ + %{label: "tree_view.ex", id: "styling-lib-tree-view-ex"}, + %{label: "tree_view_demo.ex", id: "styling-lib-tree-view-demo-ex"} + ] + }, + %{ + label: "test", + id: "styling-test", + children: [ + %{label: "tree_view_test.exs", id: "styling-test-tree-view-test-exs"} + ] + }, + %{label: "mix.exs", id: "styling-mix-exs"} + ]) + end + + defp styling_expanded, do: ["styling-lib", "styling-test"] + defp styling_value, do: ["styling-lib-tree-view-ex"] + + # -------------------------------------------------------------------------- + # Shared items string (for rendered code panels). Mirrors the accordion + # `code_items_basic()` pattern – concatenated into each code snippet below so + # the displayed code is fully self-contained (no `E2e.TreeViewDemo.*` refs). + # -------------------------------------------------------------------------- + + defp code_anatomy_items do + ~S""" + Corex.Tree.new([ + %{label: "lib", id: "lib", children: [ + %{label: "tree_view.ex", id: "lib-tree-view-ex"}, + %{label: "tree_view_demo.ex", id: "lib-tree-view-demo-ex"} + ]}, + %{label: "test", id: "test", children: [ + %{label: "tree_view_test.exs", id: "test-tree-view-test-exs"} + ]}, + %{label: "assets", id: "assets", children: [ + %{label: "tree-view.ts", id: "assets-tree-view-ts"} + ]}, + %{label: "mix.exs", id: "mix-exs"} + ]) + """ + |> String.trim_trailing("\n") + end + + defp code_api_items do + ~S""" + Corex.Tree.new([ + %{label: "corex", id: "repo-corex", children: [ + %{label: "lib", id: "repo-lib", children: [ + %{label: "tree_view.ex", id: "repo-lib-tree-view-ex"}, + %{label: "tree_view_demo.ex", id: "repo-lib-tree-view-demo-ex"} + ]}, + %{label: "mix.exs", id: "repo-mix-exs"} + ]} + ]) + """ + |> String.trim_trailing("\n") + end + + defp code_styling_items do + ~S""" + Corex.Tree.new([ + %{label: "lib", id: "lib", children: [ + %{label: "tree_view.ex", id: "lib-tree-view-ex"}, + %{label: "tree_view_demo.ex", id: "lib-tree-view-demo-ex"} + ]}, + %{label: "test", id: "test", children: [ + %{label: "tree_view_test.exs", id: "test-tree-view-test-exs"} + ]}, + %{label: "mix.exs", id: "mix-exs"} + ]) + """ + |> String.trim_trailing("\n") + end + + # -------------------------------------------------------------------------- + # Anatomy + # -------------------------------------------------------------------------- + + def anatomy_minimal_code do + """ + <.tree_view + id="tree-minimal" + class="tree-view" + expanded_value={["lib"]} + value={["lib-tree-view-ex"]} + items={#{code_anatomy_items()}} + /> + """ + end + + def anatomy_minimal_example(assigns) do + ~H""" + <.tree_view + id="tree-minimal" + class="tree-view" + expanded_value={["lib"]} + value={["lib-tree-view-ex"]} + items={anatomy_items()} + /> + """ + end + + def anatomy_with_indicator_code do + """ + <.tree_view + id="tree-with-indicator" + class="tree-view" + expanded_value={["lib"]} + value={["lib-tree-view-ex"]} + items={#{code_anatomy_items()}} + > + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + <:item_indicator><.heroicon name="hero-check" class="icon" /> + + """ + end + + def anatomy_with_indicator_example(assigns) do + ~H""" + <.tree_view + id="tree-with-indicator" + class="tree-view" + expanded_value={["lib"]} + value={["lib-tree-view-ex"]} + items={anatomy_items()} + > + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + <:item_indicator><.heroicon name="hero-check" class="icon" /> + + """ + end + + def anatomy_custom_slots_code do + """ + <.tree_view + id="tree-custom-slots" + class="tree-view" + expanded_value={["lib"]} + value={["lib-tree-view-ex"]} + items={#{code_anatomy_items()}} + > + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + <:item_indicator><.heroicon name="hero-check" class="icon" /> + <:branch :let={branch}><.heroicon name="hero-folder" class="icon" />{branch.label} + <:item :let={item}><.heroicon name="hero-document" class="icon" />{item.label} + + """ + end + + def anatomy_custom_slots_example(assigns) do + ~H""" + <.tree_view + id="tree-custom-slots" + class="tree-view" + expanded_value={["lib"]} + value={["lib-tree-view-ex"]} + items={anatomy_items()} + > + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + <:item_indicator><.heroicon name="hero-check" class="icon" /> + <:branch :let={branch}><.heroicon name="hero-folder" class="icon" />{branch.label} + <:item :let={item}><.heroicon name="hero-document" class="icon" />{item.label} + + """ + end + + def anatomy_compound_code do + """ + <.tree_view + :let={ctx} + compound + id="tree-compound" + class="tree-view" + expanded_value={["lib"]} + value={["lib-tree-view-ex"]} + items={#{code_anatomy_items()}} + > + <.tree_view_root ctx={ctx}> + <:label>Corex + <%= for item <- ctx.items do %> + <%= if item.children && item.children != [] do %> + <.tree_view_branch :let={branch} ctx={ctx} item={item}> + <.tree_view_branch_trigger branch={branch}> + {String.capitalize(item.label)} + <:indicator><.heroicon name="hero-chevron-right" /> + + <.tree_view_branch_content branch={branch}> + <%= for child <- item.children do %> + <.tree_view_item ctx={ctx} item={child} /> + <% end %> + + + <% else %> + <.tree_view_item ctx={ctx} item={item} /> + <% end %> + <% end %> + + + """ + end + + def anatomy_compound_example(assigns) do + ~H""" + <.tree_view + :let={ctx} + compound + id="tree-compound" + class="tree-view" + expanded_value={["lib"]} + value={["lib-tree-view-ex"]} + items={anatomy_items()} + > + <.tree_view_root ctx={ctx}> + <:label>Corex + + <%= for item <- ctx.items do %> + <%= if item.children && item.children != [] do %> + <.tree_view_branch :let={branch} ctx={ctx} item={item}> + <.tree_view_branch_trigger branch={branch}> + {String.capitalize(item.label)} + <:indicator><.heroicon name="hero-chevron-right" /> + + + <.tree_view_branch_content branch={branch}> + <%= for child <- item.children do %> + <.tree_view_item ctx={ctx} item={child} /> + <% end %> + + + <% else %> + <.tree_view_item ctx={ctx} item={item} /> + <% end %> + <% end %> + + + """ + end + + # -------------------------------------------------------------------------- + # Styling + # -------------------------------------------------------------------------- + + def styling_color_example(assigns) do + ~H""" + <.tree_view + id="tree-styling-color-default" + class="tree-view max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-color-accent" + class="tree-view tree-view--accent max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-color-brand" + class="tree-view tree-view--brand max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-color-info" + class="tree-view tree-view--info max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-color-alert" + class="tree-view tree-view--alert max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-color-success" + class="tree-view tree-view--success max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def styling_color_code do + items = code_styling_items() + + """ + <.tree_view class="tree-view max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--accent max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--brand max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--info max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--alert max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--success max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def styling_size_example(assigns) do + ~H""" + <.tree_view + id="tree-styling-size-sm" + class="tree-view tree-view--sm max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-size-md" + class="tree-view tree-view--md max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-size-lg" + class="tree-view tree-view--lg max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-size-xl" + class="tree-view tree-view--xl max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def styling_size_code do + items = code_styling_items() + + """ + <.tree_view class="tree-view tree-view--sm max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--md max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--lg max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--xl max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def styling_text_example(assigns) do + ~H""" + <.tree_view + id="tree-styling-text-sm" + class="tree-view tree-view--text-sm max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-text-md" + class="tree-view tree-view--text-md max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-text-lg" + class="tree-view tree-view--text-lg max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-text-xl" + class="tree-view tree-view--text-xl max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-text-2xl" + class="tree-view tree-view--text-2xl max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def styling_text_code do + items = code_styling_items() + + """ + <.tree_view class="tree-view tree-view--text-sm max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--text-md max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--text-lg max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--text-xl max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--text-2xl max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def styling_radius_example(assigns) do + ~H""" + <.tree_view + id="tree-styling-radius-none" + class="tree-view tree-view--rounded-none max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-radius-sm" + class="tree-view tree-view--rounded-sm max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-radius-md" + class="tree-view tree-view--rounded-md max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-radius-lg" + class="tree-view tree-view--rounded-lg max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-radius-xl" + class="tree-view tree-view--rounded-xl max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-radius-full" + class="tree-view tree-view--rounded-full max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def styling_radius_code do + items = code_styling_items() + + """ + <.tree_view class="tree-view tree-view--rounded-none max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--rounded-sm max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--rounded-md max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--rounded-lg max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--rounded-xl max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--rounded-full max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def styling_max_width_example(assigns) do + ~H""" + <.tree_view + id="tree-styling-max-width-2xs" + class="tree-view max-w-2xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-max-width-md" + class="tree-view max-w-md" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-max-width-xl" + class="tree-view max-w-xl" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-max-width-2xl" + class="tree-view max-w-2xl" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def styling_max_width_code do + items = code_styling_items() + + """ + <.tree_view class="tree-view max-w-2xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view max-w-md" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view max-w-xl" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view max-w-2xl" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def styling_mix_modifiers_example(assigns) do + ~H""" + <.tree_view + id="tree-styling-mix-brand" + class="tree-view tree-view--brand tree-view--sm tree-view--rounded-md max-w-xs" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-mix-alert" + class="tree-view tree-view--alert tree-view--lg tree-view--text-lg tree-view--rounded-full max-w-sm" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view + id="tree-styling-mix-success" + class="tree-view tree-view--success tree-view--xl tree-view--text-xl tree-view--rounded-lg max-w-md" + expanded_value={styling_expanded()} + value={styling_value()} + items={styling_items()} + > + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + def styling_mix_modifiers_code do + items = code_styling_items() + + """ + <.tree_view class="tree-view tree-view--brand tree-view--sm tree-view--rounded-md max-w-xs" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--alert tree-view--lg tree-view--text-lg tree-view--rounded-full max-w-sm" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + <.tree_view class="tree-view tree-view--success tree-view--xl tree-view--text-xl tree-view--rounded-lg max-w-md" items={#{items}}> + <:branch_indicator><.heroicon name="hero-chevron-right" class="icon" /> + + """ + end + + # -------------------------------------------------------------------------- + # API — Set Expanded + # -------------------------------------------------------------------------- + + def api_set_expanded_client_example(assigns) do + ~H""" +
+ <.action + phx-click={Corex.TreeView.set_expanded_value(@id, ["repo-lib"])} + class="button button--sm" + > + Expand lib + + <.action phx-click={Corex.TreeView.set_expanded_value(@id, [])} class="button button--sm"> + Collapse all + +
+ <.tree_view id={@id} class="tree-view" expanded_value={[]} items={@items}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_set_expanded_client_heex do + """ + <.action phx-click={Corex.TreeView.set_expanded_value("tree-api-set-expanded-client", ["repo-lib"])}>Expand lib + <.action phx-click={Corex.TreeView.set_expanded_value("tree-api-set-expanded-client", [])}>Collapse all + <.tree_view id="tree-api-set-expanded-client" class="tree-view" expanded_value={[]} items={#{code_api_items()}}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_set_expanded_client_js_example(assigns) do + ~H""" +
+ <.action + phx-click={ + JS.dispatch("corex:tree-view:set-expanded-value", + to: "##{@id}", + detail: %{value: ["repo-lib"]}, + bubbles: false + ) + } + class="button button--sm" + > + Expand lib + + <.action + phx-click={ + JS.dispatch("corex:tree-view:set-expanded-value", + to: "##{@id}", + detail: %{value: []}, + bubbles: false + ) + } + class="button button--sm" + > + Collapse all + +
+ <.tree_view id={@id} class="tree-view" expanded_value={[]} items={@items}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_set_expanded_js_heex do + """ + <.action phx-click={JS.dispatch("corex:tree-view:set-expanded-value", to: "#tree-api-set-expanded-js", detail: %{value: ["repo-lib"]}, bubbles: false)}>Expand lib + <.action phx-click={JS.dispatch("corex:tree-view:set-expanded-value", to: "#tree-api-set-expanded-js", detail: %{value: []}, bubbles: false)}>Collapse all + <.tree_view id="tree-api-set-expanded-js" class="tree-view" expanded_value={[]} items={#{code_api_items()}}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_set_expanded_js_js do + ~S""" + const el = document.getElementById("tree-api-set-expanded-js"); + el?.dispatchEvent( + new CustomEvent("corex:tree-view:set-expanded-value", { + bubbles: false, + detail: { value: ["repo-lib"] }, + }) + ); + el?.dispatchEvent( + new CustomEvent("corex:tree-view:set-expanded-value", { + bubbles: false, + detail: { value: [] }, + }) + ); + """ + end + + def api_set_expanded_js_ts do + ~S""" + const el = document.getElementById("tree-api-set-expanded-js"); + const setExpanded = (value: string[]) => + el?.dispatchEvent( + new CustomEvent("corex:tree-view:set-expanded-value", { + bubbles: false, + detail: { value }, + }) + ); + setExpanded(["repo-lib"]); + setExpanded([]); + """ + end + + def api_set_expanded_server_example(assigns) do + ~H""" +
+ <.action phx-click={@event} value="repo-lib" class="button button--sm">Expand lib + <.action phx-click={@event} value="" class="button button--sm">Collapse all +
+ <.tree_view id={@id} class="tree-view" expanded_value={[]} items={@items}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_set_expanded_server_heex do + """ + <.action phx-click="tree_api_set_expanded" value="repo-lib">Expand lib + <.action phx-click="tree_api_set_expanded" value="">Collapse all + <.tree_view id="tree-api-set-expanded-server" class="tree-view" expanded_value={[]} items={#{code_api_items()}}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_set_expanded_server_elixir do + ~S""" + def handle_event("tree_api_set_expanded", %{"value" => raw}, socket) do + list = if raw == "", do: [], else: String.split(raw, ",", trim: true) + {:noreply, Corex.TreeView.set_expanded_value(socket, "tree-api-set-expanded-server", list)} + end + """ + end + + # -------------------------------------------------------------------------- + # API — Set Selected + # -------------------------------------------------------------------------- + + def api_set_selected_client_example(assigns) do + ~H""" +
+ <.action + phx-click={Corex.TreeView.set_selected_value(@id, ["repo-lib-tree-view-ex"])} + class="button button--sm" + > + Select tree_view.ex + + <.action phx-click={Corex.TreeView.set_selected_value(@id, [])} class="button button--sm"> + Clear + +
+ <.tree_view id={@id} class="tree-view" expanded_value={["repo-lib"]} items={@items}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_set_selected_client_heex do + """ + <.action phx-click={Corex.TreeView.set_selected_value("tree-api-set-selected-client", ["repo-lib-tree-view-ex"])}>Select tree_view.ex + <.action phx-click={Corex.TreeView.set_selected_value("tree-api-set-selected-client", [])}>Clear + <.tree_view id="tree-api-set-selected-client" class="tree-view" expanded_value={["repo-lib"]} items={#{code_api_items()}}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_set_selected_client_js_example(assigns) do + ~H""" +
+ <.action + phx-click={ + JS.dispatch("corex:tree-view:set-selected-value", + to: "##{@id}", + detail: %{value: ["repo-lib-tree-view-ex"]}, + bubbles: false + ) + } + class="button button--sm" + > + Select tree_view.ex + + <.action + phx-click={ + JS.dispatch("corex:tree-view:set-selected-value", + to: "##{@id}", + detail: %{value: []}, + bubbles: false + ) + } + class="button button--sm" + > + Clear + +
+ <.tree_view id={@id} class="tree-view" expanded_value={["repo-lib"]} items={@items}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_set_selected_js_heex do + """ + <.action phx-click={JS.dispatch("corex:tree-view:set-selected-value", to: "#tree-api-set-selected-js", detail: %{value: ["repo-lib-tree-view-ex"]}, bubbles: false)}>Select tree_view.ex + <.action phx-click={JS.dispatch("corex:tree-view:set-selected-value", to: "#tree-api-set-selected-js", detail: %{value: []}, bubbles: false)}>Clear + <.tree_view id="tree-api-set-selected-js" class="tree-view" expanded_value={["repo-lib"]} items={#{code_api_items()}}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_set_selected_js_js do + ~S""" + const el = document.getElementById("tree-api-set-selected-js"); + el?.dispatchEvent( + new CustomEvent("corex:tree-view:set-selected-value", { + bubbles: false, + detail: { value: ["repo-lib-tree-view-ex"] }, + }) + ); + el?.dispatchEvent( + new CustomEvent("corex:tree-view:set-selected-value", { + bubbles: false, + detail: { value: [] }, + }) + ); + """ + end + + def api_set_selected_js_ts do + ~S""" + const el = document.getElementById("tree-api-set-selected-js"); + const setSelected = (value: string[]) => + el?.dispatchEvent( + new CustomEvent("corex:tree-view:set-selected-value", { + bubbles: false, + detail: { value }, + }) + ); + setSelected(["repo-lib-tree-view-ex"]); + setSelected([]); + """ + end + + def api_set_selected_server_example(assigns) do + ~H""" +
+ <.action phx-click={@event} value="repo-lib-tree-view-ex" class="button button--sm"> + Select tree_view.ex + + <.action phx-click={@event} value="" class="button button--sm">Clear +
+ <.tree_view id={@id} class="tree-view" expanded_value={["repo-lib"]} items={@items}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_set_selected_server_heex do + """ + <.action phx-click="tree_api_set_selected" value="repo-lib-tree-view-ex">Select tree_view.ex + <.action phx-click="tree_api_set_selected" value="">Clear + <.tree_view id="tree-api-set-selected-server" class="tree-view" expanded_value={["repo-lib"]} items={#{code_api_items()}}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_set_selected_server_elixir do + ~S""" + def handle_event("tree_api_set_selected", %{"value" => raw}, socket) do + list = if raw == "", do: [], else: String.split(raw, ",", trim: true) + {:noreply, Corex.TreeView.set_selected_value(socket, "tree-api-set-selected-server", list)} + end + """ + end + + # -------------------------------------------------------------------------- + # API — Get Expanded / Selected (server) + # -------------------------------------------------------------------------- + + def api_get_expanded_server_example(assigns) do + ~H""" +
+ <.action phx-click={@event} class="button button--sm">Get expanded +
+ <.tree_view id={@id} class="tree-view" expanded_value={["repo-lib"]} items={@items}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_get_expanded_server_heex do + """ + <.action phx-click="tree_api_get_expanded">Get expanded + <.tree_view id="tree-api-get-expanded-server" class="tree-view" expanded_value={["repo-lib"]} items={#{code_api_items()}}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_get_expanded_server_elixir do + ~S""" + def handle_event("tree_api_get_expanded", _params, socket) do + {:noreply, push_event(socket, "tree_view_expanded_value", %{})} + end + + def handle_event("tree_view_expanded_value_response", %{"id" => id, "value" => value}, socket) do + desc = "#{id}\n#{inspect(value)}" + {:noreply, Corex.Toast.push_toast(socket, "layout-toast", "tree_expanded", desc, :info, 5000)} + end + """ + end + + def api_get_selected_server_example(assigns) do + ~H""" +
+ <.action phx-click={@event} class="button button--sm">Get selected +
+ <.tree_view + id={@id} + class="tree-view" + expanded_value={["repo-lib"]} + value={["repo-lib-tree-view-ex"]} + items={@items} + > + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_get_selected_server_heex do + """ + <.action phx-click="tree_api_get_selected">Get selected + <.tree_view id="tree-api-get-selected-server" class="tree-view" expanded_value={["repo-lib"]} value={["repo-lib-tree-view-ex"]} items={#{code_api_items()}}> + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def api_get_selected_server_elixir do + ~S""" + def handle_event("tree_api_get_selected", _params, socket) do + {:noreply, push_event(socket, "tree_view_selected_value", %{})} + end + + def handle_event("tree_view_selected_value_response", %{"id" => id, "value" => value}, socket) do + desc = "#{id}\n#{inspect(value)}" + {:noreply, Corex.Toast.push_toast(socket, "layout-toast", "tree_selected", desc, :info, 5000)} + end + """ + end + + def api_codes do + %{ + set_expanded_client_heex: api_set_expanded_client_heex(), + set_expanded_js_heex: api_set_expanded_js_heex(), + set_expanded_js: api_set_expanded_js_js(), + set_expanded_js_ts: api_set_expanded_js_ts(), + set_expanded_server_heex: api_set_expanded_server_heex(), + set_expanded_server_elixir: api_set_expanded_server_elixir(), + set_selected_client_heex: api_set_selected_client_heex(), + set_selected_js_heex: api_set_selected_js_heex(), + set_selected_js: api_set_selected_js_js(), + set_selected_js_ts: api_set_selected_js_ts(), + set_selected_server_heex: api_set_selected_server_heex(), + set_selected_server_elixir: api_set_selected_server_elixir(), + get_expanded_heex: api_get_expanded_server_heex(), + get_expanded_elixir: api_get_expanded_server_elixir(), + get_selected_heex: api_get_selected_server_heex(), + get_selected_elixir: api_get_selected_server_elixir() + } + end + + # -------------------------------------------------------------------------- + # Events + # -------------------------------------------------------------------------- + + def events_items, do: api_items() + + def events_server_heex do + """ + <.tree_view + id="tree-events-server" + class="tree-view" + items={#{code_api_items()}} + on_expanded_change="tree_server_expanded" + on_selection_change="tree_server_selection" + > + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def events_server_elixir do + ~S""" + def mount(_params, _session, socket) do + {:ok, socket |> stream(:server_logs, [])} + end + + def handle_event("tree_server_expanded", %{"id" => id, "expandedValue" => expanded}, socket) do + log = new_log("server", id, "expanded", expanded) + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + + def handle_event("tree_server_selection", %{"id" => id} = payload, socket) do + log = new_log("server", id, "selection", Map.drop(payload, ["id"])) + {:noreply, stream_insert(socket, :server_logs, log, at: 0)} + end + """ + end + + def events_client_heex do + """ + <.tree_view + id="tree-events-client" + class="tree-view" + expanded_value={["repo-lib"]} + items={#{code_api_items()}} + on_expanded_change_client="tree-view-expanded-client" + on_selection_change_client="tree-view-selection-client" + > + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def events_client_js do + ~S""" + const el = document.getElementById("tree-events-client"); + el?.addEventListener("tree-view-expanded-client", (event) => { + const d = event.detail; + console.log("expanded", d.id, d.expandedValue, "added:", d.added, "removed:", d.removed); + }); + el?.addEventListener("tree-view-selection-client", (event) => { + const d = event.detail; + console.log("selection", d.id, d.selectedValue, "isItem:", d.isItem); + }); + """ + end + + def events_client_ts do + ~S""" + import type { + TreeViewExpandedChangedDetail, + TreeViewSelectionChangedDetail, + } from "corex"; + + const el = document.getElementById("tree-events-client"); + el?.addEventListener("tree-view-expanded-client", (event: Event) => { + const d = (event as CustomEvent).detail; + console.log("expanded", d.id, d.expandedValue, "added:", d.added, "removed:", d.removed); + }); + el?.addEventListener("tree-view-selection-client", (event: Event) => { + const d = (event as CustomEvent).detail; + console.log("selection", d.id, d.selectedValue, "isItem:", d.isItem); + }); + """ + end + + # -------------------------------------------------------------------------- + # Animation + # -------------------------------------------------------------------------- + + def animation_items, do: api_items() + + def animation_expanded_default, do: ["repo-corex", "repo-lib"] + + def animation_playground_heex do + """ + <.tree_view + id="tree-animation-playground" + class="tree-view" + animation="js" + animation_options={ + %Corex.Animation.Height{ + duration: 0.3, + easing: "ease", + opacity_start: 0, + opacity_end: 1 + } + } + expanded_value={["repo-corex", "repo-lib"]} + items={#{code_api_items()}} + > + <:label>Corex + <:branch_indicator><.heroicon name="hero-chevron-right" /> + + """ + end + + def animation_instant_heex do + """ + <.tree_view + class="tree-view" + animation="instant" + expanded_value={["repo-corex", "repo-lib"]} + items={#{code_api_items()}} + /> + """ + end + + def animation_custom_heex do + """ + <.tree_view + class="tree-view" + animation="custom" + expanded_value={["repo-corex", "repo-lib"]} + on_expanded_change_client="my-tree-view-changed" + items={#{code_api_items()}} + /> + """ + end + + def animation_custom_js do + ~S""" + import { animate } from "motion" + import { + initCustomCollections, + findTreeBranch, + animateHeightOpen, + animateHeightClose, + } from "corex" + + const reducedMotion = () => + window.matchMedia("(prefers-reduced-motion: reduce)").matches + + document.addEventListener("DOMContentLoaded", initCustomCollections) + window.addEventListener("phx:page-loading-stop", initCustomCollections) + + document.addEventListener("my-tree-view-changed", (e) => { + const root = document.getElementById(e.detail.id) + if (!root) return + e.detail.added.forEach((v) => { + const el = findTreeBranch(root, v) + if (!el) return + animateHeightOpen(el, { animator: animate, duration: 0.5, easing: [0.16, 1, 0.3, 1] }) + if (!reducedMotion()) { + animate( + el, + { filter: ["blur(8px)", "blur(0px)"], y: [-10, 0] }, + { duration: 0.55, easing: [0.16, 1, 0.3, 1] }, + ) + } + }) + e.detail.removed.forEach((v) => { + const el = findTreeBranch(root, v) + if (!el) return + animateHeightClose(el, { animator: animate, duration: 0.28, easing: "ease-in" }) + if (!reducedMotion()) { + animate( + el, + { filter: ["blur(0px)", "blur(8px)"], y: [0, -8] }, + { duration: 0.26, easing: "ease-in" }, + ) + } + }) + }) + """ + end + + # -------------------------------------------------------------------------- + # Patterns — Redirect (Navigate) + # -------------------------------------------------------------------------- + + @doc """ + Navigation tree used by the redirect pattern. Node ids are built with + verified routes (`~p"..."`) which automatically include the current locale + prefix via `path_prefixes` on `Phoenix.VerifiedRoutes` (Gettext locale). + """ + def patterns_redirect_items do + Corex.Tree.new([ + %{ + label: "Accordion", + id: ~p"/accordion/anatomy", + children: [ + %{label: "Structure", id: ~p"/accordion/anatomy"}, + %{label: "Playground", id: ~p"/accordion/playground"} + ] + }, + %{ + label: "Tree view", + id: ~p"/tree-view/anatomy", + children: [ + %{label: "Structure", id: ~p"/tree-view/anatomy"}, + %{label: "Playground", id: ~p"/tree-view/playground"} + ] + } + ]) + end + + def patterns_redirect_expanded, do: [~p"/accordion/anatomy"] + def patterns_redirect_value, do: [~p"/tree-view/anatomy"] + + def patterns_redirect_heex do + ~S""" + <.tree_view + id="patterns-tree-redirect" + class="tree-view" + redirect + on_selection_change="patterns_tree_redirect_nav" + expanded_value={[~p"/accordion/anatomy"]} + value={[~p"/tree-view/anatomy"]} + items={ + Corex.Tree.new([ + %{ + label: "Accordion", + id: ~p"/accordion/anatomy", + children: [ + %{label: "Structure", id: ~p"/accordion/anatomy"}, + %{label: "Playground", id: ~p"/accordion/playground"} + ] + }, + %{ + label: "Tree view", + id: ~p"/tree-view/anatomy", + children: [ + %{label: "Structure", id: ~p"/tree-view/anatomy"}, + %{label: "Playground", id: ~p"/tree-view/playground"} + ] + } + ]) + } + > + <:label>Navigate + <:branch_indicator :let={_row}> + <.heroicon name="hero-chevron-right" /> + + + """ + end +end diff --git a/e2e/lib/e2e_web/doc_a11y_routes.ex b/e2e/lib/e2e_web/doc_a11y_routes.ex new file mode 100644 index 00000000..aa9ac959 --- /dev/null +++ b/e2e/lib/e2e_web/doc_a11y_routes.ex @@ -0,0 +1,211 @@ +defmodule E2eWeb.DocA11yRoutes do + @moduledoc false + + @locale "en" + + def locale, do: @locale + + @routes [ + {"/en/accordion/playground", "#my-accordion"}, + {"/en/accordion/anatomy", "#accordion-anatomy-page"}, + {"/en/accordion/api", "#accordion-api-page"}, + {"/en/accordion/events", "#accordion-events-page"}, + {"/en/accordion/patterns", "#accordion-patterns-page"}, + {"/en/accordion/animation", "#accordion-animation-page"}, + {"/en/accordion/style", "#accordion-styling-page"}, + {"/en/action/anatomy", "#action-anatomy-page"}, + {"/en/action/style", "#action-style-page"}, + {"/en/angle-slider/playground", "#my-angle-slider"}, + {"/en/angle-slider/anatomy", "#angle-slider-anatomy-page"}, + {"/en/angle-slider/api", "#angle-slider-api-page"}, + {"/en/angle-slider/events", "#angle-slider-events-page"}, + {"/en/angle-slider/patterns", "#angle-slider-patterns-page"}, + {"/en/angle-slider/style", "#angle-slider-styling-page"}, + {"/en/angle-slider/form", "#angle-slider-form-page"}, + {"/en/angle-slider/live-form", "#angle-slider-form-live-page"}, + {"/en/avatar/playground", "#avatar-playground"}, + {"/en/avatar/anatomy", "#avatar-anatomy-page"}, + {"/en/avatar/api", "#avatar-api-page"}, + {"/en/avatar/style", "#avatar-styling-page"}, + {"/en/avatar/events", "#avatar-events-page"}, + {"/en/carousel/playground", "#carousel-playground"}, + {"/en/carousel/anatomy", "#carousel-anatomy-page"}, + {"/en/carousel/api", "#carousel-api-page"}, + {"/en/carousel/events", "#carousel-events-page"}, + {"/en/carousel/style", "#carousel-styling-page"}, + {"/en/checkbox/playground", "#checkbox-playground"}, + {"/en/checkbox/anatomy", "#checkbox-anatomy-page"}, + {"/en/checkbox/api", "#checkbox-api-page"}, + {"/en/checkbox/events", "#checkbox-events-page"}, + {"/en/checkbox/patterns", "#checkbox-patterns-page"}, + {"/en/checkbox/style", "#checkbox-styling-page"}, + {"/en/checkbox/form", "#checkbox-form-page"}, + {"/en/checkbox/live-form", "#checkbox-form-live-page"}, + {"/en/clipboard/playground", "#clipboard-playground"}, + {"/en/clipboard/anatomy", "#clipboard-anatomy-page"}, + {"/en/clipboard/api", "#clipboard-api-page"}, + {"/en/clipboard/events", "#clipboard-events-page"}, + {"/en/clipboard/style", "#clipboard-styling-page"}, + {"/en/collapsible/playground", "#collapsible-playground"}, + {"/en/collapsible/anatomy", "#collapsible-anatomy-page"}, + {"/en/collapsible/api", "#collapsible-api-page"}, + {"/en/collapsible/events", "#collapsible-events-page"}, + {"/en/collapsible/patterns", "#collapsible-patterns-page"}, + {"/en/collapsible/style", "#collapsible-styling-page"}, + {"/en/code/anatomy", "#code-anatomy-page"}, + {"/en/code/style", "#code-styling-page"}, + {"/en/color-picker/playground", "#color-picker-playground"}, + {"/en/color-picker/anatomy", "#color-picker-anatomy-page"}, + {"/en/color-picker/api", "#color-picker-api-page"}, + {"/en/color-picker/events", "#color-picker-events-page"}, + {"/en/color-picker/form", "#color-picker-form-page"}, + {"/en/color-picker/live-form", "#color-picker-form-live-page"}, + {"/en/combobox/playground", "#combobox-playground"}, + {"/en/combobox/anatomy", "#combobox-anatomy-page"}, + {"/en/combobox/api", "#combobox-api-page"}, + {"/en/combobox/events", "#combobox-events-page"}, + {"/en/combobox/patterns", "#combobox-patterns-page"}, + {"/en/combobox/style", "#combobox-styling-page"}, + {"/en/combobox/form", "#combobox-form-page"}, + {"/en/combobox/live-form", "#combobox-form-live-page"}, + {"/en/data-list/anatomy", "#data-list-anatomy-page"}, + {"/en/data-table/anatomy", "#data-table-anatomy-page"}, + {"/en/data-table/patterns", "#data-table-patterns-page"}, + {"/en/date-picker/playground", "#date-picker-playground"}, + {"/en/date-picker/anatomy", "#date-picker-anatomy-page"}, + {"/en/date-picker/api", "#date-picker-api-page"}, + {"/en/date-picker/events", "#date-picker-events-page"}, + {"/en/date-picker/patterns", "#date-picker-patterns-page"}, + {"/en/date-picker/form", "#date-picker-form-page"}, + {"/en/date-picker/live-form", "#date-picker-form-live-page"}, + {"/en/dialog/playground", "#dialog-playground"}, + {"/en/dialog/anatomy", "#dialog-anatomy-page"}, + {"/en/dialog/api", "#dialog-api-page"}, + {"/en/dialog/events", "#dialog-events-page"}, + {"/en/dialog/patterns", "#dialog-patterns-page"}, + {"/en/dialog/animation", "#dialog-animation-page"}, + {"/en/dialog/style", "#dialog-styling-page"}, + {"/en/editable/playground", "#editable-playground"}, + {"/en/editable/anatomy", "#editable-anatomy-page"}, + {"/en/editable/api", "#editable-api-page"}, + {"/en/editable/events", "#editable-events-page"}, + {"/en/editable/style", "#editable-styling-page"}, + {"/en/editable/form", "#editable-form-page"}, + {"/en/editable/live-form", "#editable-form-live-page"}, + {"/en/floating-panel/playground", "#floating-panel-play-page"}, + {"/en/floating-panel/anatomy", "#floating-panel-anatomy-page"}, + {"/en/floating-panel/api", "#floating-panel-api-page"}, + {"/en/floating-panel/events", "#floating-panel-events-page"}, + {"/en/layout-heading/anatomy", "#layout-heading-anatomy-page"}, + {"/en/layout-heading/style", "#layout-heading-styling-page"}, + {"/en/listbox/playground", "#listbox-playground-page"}, + {"/en/listbox/anatomy", "#listbox-anatomy-page"}, + {"/en/listbox/api", "#listbox-api-page"}, + {"/en/listbox/events", "#listbox-events-page"}, + {"/en/listbox/patterns", "#listbox-patterns-stream"}, + {"/en/marquee/anatomy", "#marquee-anatomy-page"}, + {"/en/marquee/api", "#marquee-api-page"}, + {"/en/marquee/events", "#marquee-events-page"}, + {"/en/menu/playground", "#menu-playground-page"}, + {"/en/menu/anatomy", "#menu-anatomy-page"}, + {"/en/menu/api", "#menu-api-page"}, + {"/en/menu/events", "#menu-events-page"}, + {"/en/menu/patterns", "#menu-patterns-page"}, + {"/en/native-input/anatomy", "#native-input-anatomy-page"}, + {"/en/native-input/form", "#native-input-form-page"}, + {"/en/native-input/live-form", "#native-input-form-live-page"}, + {"/en/navigate/anatomy", "#navigate-anatomy-page"}, + {"/en/navigate/style", "#navigate-style-page"}, + {"/en/number-input/playground", "#number-input-playground"}, + {"/en/number-input/anatomy", "#number-input-anatomy-page"}, + {"/en/number-input/api", "#number-input-api-page"}, + {"/en/number-input/events", "#number-input-events-page"}, + {"/en/number-input/style", "#number-input-styling-page"}, + {"/en/number-input/form", "#number-input-form-page"}, + {"/en/number-input/live-form", "#number-input-form-live-page"}, + {"/en/password-input/playground", "#password-input-playground"}, + {"/en/password-input/anatomy", "#password-input-anatomy-page"}, + {"/en/password-input/api", "#password-input-api-page"}, + {"/en/password-input/events", "#password-input-events-page"}, + {"/en/password-input/form", "#password-input-form-page"}, + {"/en/password-input/live-form", "#password-input-form-live-page"}, + {"/en/pin-input/playground", "#pin-input-playground"}, + {"/en/pin-input/anatomy", "#pin-input-anatomy-page"}, + {"/en/pin-input/api", "#pin-input-api-page"}, + {"/en/pin-input/events", "#pin-input-events-page"}, + {"/en/pin-input/form", "#pin-input-form-page"}, + {"/en/pin-input/live-form", "#pin-input-form-live-page"}, + {"/en/radio-group/playground", "#radio-group-playground"}, + {"/en/radio-group/anatomy", "#radio-group-anatomy-page"}, + {"/en/radio-group/api", "#radio-group-api-page"}, + {"/en/radio-group/events", "#radio-group-events-page"}, + {"/en/radio-group/patterns", "#radio-group-patterns-page"}, + {"/en/radio-group/form", "#radio-group-form-page"}, + {"/en/radio-group/live-form", "#radio-group-form-live-page"}, + {"/en/select/playground", "#select-playground"}, + {"/en/select/anatomy", "#select-anatomy-page"}, + {"/en/select/api", "#select-api-page"}, + {"/en/select/events", "#select-events-page"}, + {"/en/select/patterns", "#select-patterns-page"}, + {"/en/select/style", "#select-styling-page"}, + {"/en/select/form", "#select-form-page"}, + {"/en/select/live-form", "#select-form-live-page"}, + {"/en/signature/playground", "#signature-playground"}, + {"/en/signature/anatomy", "#signature-anatomy-page"}, + {"/en/signature/api", "#signature-api-page"}, + {"/en/signature/events", "#signature-events-page"}, + {"/en/signature/form", "#signature-form-page"}, + {"/en/signature/live-form", "#signature-form-live-page"}, + {"/en/switch/playground", "#switch-playground"}, + {"/en/switch/anatomy", "#switch-anatomy-page"}, + {"/en/switch/api", "#switch-api-page"}, + {"/en/switch/events", "#switch-events-page"}, + {"/en/switch/patterns", "#switch-patterns-page"}, + {"/en/switch/style", "#switch-styling-page"}, + {"/en/switch/form", "#switch-form-page"}, + {"/en/switch/live-form", "#switch-form-live-page"}, + {"/en/tabs/playground", "#tabs-playground"}, + {"/en/tabs/anatomy", "#tabs-anatomy-page"}, + {"/en/tabs/api", "#tabs-api-page"}, + {"/en/tabs/events", "#tabs-events-page"}, + {"/en/tabs/patterns", "#tabs-patterns-page"}, + {"/en/tabs/style", "#tabs-styling-page"}, + {"/en/timer/playground", "#timer-playground"}, + {"/en/timer/anatomy", "#timer-anatomy-page"}, + {"/en/timer/api", "#timer-api-page"}, + {"/en/timer/events", "#timer-events-page"}, + {"/en/timer/patterns", "#timer-patterns-page"}, + {"/en/timer/style", "#timer-styling-page"}, + {"/en/toast/playground", "#toast-playground"}, + {"/en/toast/anatomy", "#toast-anatomy-page"}, + {"/en/toast/api", "#toast-api-page"}, + {"/en/toast/patterns", "#toast-patterns-page"}, + {"/en/toggle-group/playground", "#toggle-group-playground-page"}, + {"/en/toggle-group/anatomy", "#toggle-group-anatomy-page"}, + {"/en/toggle-group/api", "#toggle-group-api-page"}, + {"/en/toggle-group/events", "#toggle-group-events-page"}, + {"/en/toggle-group/patterns", "#toggle-group-patterns-page"}, + {"/en/toggle-group/style", "#toggle-group-styling-page"}, + {"/en/tooltip/playground", "#tooltip-playground-page"}, + {"/en/tooltip/anatomy", "#tooltip-anatomy-page"}, + {"/en/tooltip/api", "#tooltip-api-page"}, + {"/en/tooltip/events", "#tooltip-events-page"}, + {"/en/tooltip/patterns", "#tooltip-patterns-page"}, + {"/en/tooltip/style", "#tooltip-styling-page"}, + {"/en/tree-view/playground", "#playground-tree"}, + {"/en/tree-view/anatomy", "#tree-view-anatomy-page"}, + {"/en/tree-view/api", "#tree-view-api-page"}, + {"/en/tree-view/events", "#tree-view-events-page"}, + {"/en/tree-view/patterns", "#tree-view-patterns-page"}, + {"/en/tree-view/animation", "#tree-view-animation-page"}, + {"/en/tree-view/style", "#tree-view-styling-page"}, + {"/en/angle-slider/controlled", "#my-angle-slider"} + ] + + def all, do: @routes + + def for_slug(slug) when is_binary(slug) do + needle = "/#{slug}/" + Enum.filter(@routes, fn {path, _} -> String.contains?(path, needle) end) + end +end diff --git a/e2e/lib/e2e_web/doc_routes.ex b/e2e/lib/e2e_web/doc_routes.ex new file mode 100644 index 00000000..953312a0 --- /dev/null +++ b/e2e/lib/e2e_web/doc_routes.ex @@ -0,0 +1,7 @@ +defmodule E2eWeb.DocRoutes do + @moduledoc false + + defdelegate locale(), to: E2eWeb.DocA11yRoutes + defdelegate all(), to: E2eWeb.DocA11yRoutes + defdelegate for_slug(slug), to: E2eWeb.DocA11yRoutes +end diff --git a/e2e/lib/e2e_web/endpoint.ex b/e2e/lib/e2e_web/endpoint.ex index 2e593cd5..7eb16c33 100644 --- a/e2e/lib/e2e_web/endpoint.ex +++ b/e2e/lib/e2e_web/endpoint.ex @@ -1,6 +1,10 @@ defmodule E2eWeb.Endpoint do use Phoenix.Endpoint, otp_app: :corex_web + if Application.compile_env(:corex_web, :sql_sandbox, false) do + plug Phoenix.Ecto.SQL.Sandbox + end + # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. # Set :encryption_salt if you would also like to encrypt it. @@ -32,6 +36,10 @@ defmodule E2eWeb.Endpoint do plug Tidewave end + if Mix.env() in [:dev, :test] do + plug Corex.MCP + end + # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. if code_reloading? do diff --git a/e2e/lib/e2e_web/live/accordion_async_live.ex b/e2e/lib/e2e_web/live/accordion_async_live.ex deleted file mode 100644 index fdc14caf..00000000 --- a/e2e/lib/e2e_web/live/accordion_async_live.ex +++ /dev/null @@ -1,74 +0,0 @@ -defmodule E2eWeb.AccordionAsyncLive do - use E2eWeb, :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." - ], - [ - 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""" - - <.layout_heading> - <:title>Accordion - <:subtitle>Async - - - <.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 diff --git a/e2e/lib/e2e_web/live/accordion_controlled_live.ex b/e2e/lib/e2e_web/live/accordion_controlled_live.ex deleted file mode 100644 index d3691a1a..00000000 --- a/e2e/lib/e2e_web/live/accordion_controlled_live.ex +++ /dev/null @@ -1,111 +0,0 @@ -defmodule E2eWeb.AccordionControlledLive do - use E2eWeb, :live_view - - def mount(_params, _session, socket) do - socket = - socket - |> assign(:value, ["lorem"]) - |> assign(:items, accordion_items()) - - {:ok, socket} - end - - defp accordion_items do - 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." - ], - [ - id: "donec", - trigger: "Donec condimentum ex mi", - content: - "Congue molestie ipsum gravida a. Sed ac eros luctus, cursus turpis non, pellentesque elit. Pellentesque sagittis fermentum." - ] - ]) - end - - def handle_event("on_value_change", %{"id" => _id, "value" => value}, socket) do - {:noreply, assign(socket, :value, value)} - end - - def handle_event("on_focus_change", %{"id" => _id, "value" => _value}, socket) do - {:noreply, socket} - end - - def handle_event("set_value", %{"value" => value}, socket) do - {:noreply, Corex.Accordion.set_value(socket, "my-accordion", String.split(value, ","))} - end - - def render(assigns) do - ~H""" - - <.layout_heading> - <:title>Accordion - <:subtitle>Live View - - -

Client Api

-
- <.action - phx-click={Corex.Accordion.set_value("my-accordion", ["lorem"])} - class="button button--sm" - > - Open Item 1 - - <.action - phx-click={Corex.Accordion.set_value("my-accordion", ["lorem", "duis"])} - class="button button--sm" - > - Open Item 1 and 2 - - <.action phx-click={Corex.Accordion.set_value("my-accordion", [])} class="button button--sm"> - Close all Items - -
-

Server Api

-
- <.action phx-click="set_value" value={Enum.join(["lorem"], ",")} class="button button--sm"> - Open Item 1 - - <.action - phx-click="set_value" - value={Enum.join(["lorem", "duis"], ",")} - class="button button--sm" - > - Open Item 1 and 2 - - <.action phx-click="set_value" value="" class="button button--sm"> - Close all Items - -
- <.accordion - id="my-accordion" - class="accordion" - items={@items} - value={@value} - controlled - on_value_change="on_value_change" - on_focus_change="on_focus_change" - > - <:trigger :let={item}>{item.data.trigger} - <:content :let={item}>{item.data.content} - -
- """ - end -end diff --git a/e2e/lib/e2e_web/live/accordion_live.ex b/e2e/lib/e2e_web/live/accordion_live.ex deleted file mode 100644 index c4e9380a..00000000 --- a/e2e/lib/e2e_web/live/accordion_live.ex +++ /dev/null @@ -1,121 +0,0 @@ -defmodule E2eWeb.AccordionLive do - use E2eWeb, :live_view - - def mount(_params, _session, socket) do - socket = - socket - |> assign(:accordion_value, nil) - |> assign(:accordion_focused_value, nil) - - {:ok, socket} - end - - def handle_event("set_value", %{"value" => value}, socket) do - {:noreply, Corex.Accordion.set_value(socket, "my-accordion", String.split(value, ","))} - end - - def handle_event("get_value", _params, socket) do - {:noreply, push_event(socket, "accordion_value", %{})} - end - - def handle_event("accordion_value_response", %{"value" => value}, socket) do - {:noreply, assign(socket, :accordion_value, value)} - end - - def handle_event("accordion_focused_value_response", %{"value" => value}, socket) do - {:noreply, assign(socket, :accordion_focused_value, value)} - end - - def render(assigns) do - ~H""" - - <.layout_heading> - <:title>Accordion - <:subtitle>Live View - - -

Client Api

-
- <.action - phx-click={Corex.Accordion.set_value("my-accordion", ["lorem"])} - class="button button--sm" - > - Open Lorem - - <.action - phx-click={Corex.Accordion.set_value("my-accordion", ["lorem", "donec"])} - class="button button--sm" - > - Open Lorem & Donec - - <.action phx-click={Corex.Accordion.set_value("my-accordion", [])} class="button button--sm"> - Close all Items - -
-

Server Api

-
- <.action phx-click="set_value" value={Enum.join(["lorem"], ",")} class="button button--sm"> - Open Lorem - - <.action - phx-click="set_value" - value={Enum.join(["lorem", "donec"], ",")} - class="button button--sm" - > - Open Lorem & Donec - - <.action phx-click="set_value" value="" class="button button--sm"> - Close all Items - - <.action phx-click="get_value" class="button button--sm"> - Get current value - -
-
-

- Current value: {inspect(@accordion_value)} -

-

- Focused value: {inspect(@accordion_focused_value)} -

-
- <.accordion - class="accordion" - id="my-accordion" - items={ - Corex.Content.new([ - [ - id: "lorem", - trigger: "Lorem ipsum dolor sit amet", - content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique.", - meta: %{indicator: "hero-chevron-right"} - ], - [ - trigger: "Duis dictum gravida odio ac pharetra?", - content: "Nullam eget vestibulum ligula, at interdum tellus.", - meta: %{indicator: "hero-chevron-right"} - ], - [ - id: "donec", - trigger: "Donec condimentum ex mi", - content: "Congue molestie ipsum gravida a. Sed ac eros luctus.", - disabled: true, - meta: %{indicator: "hero-chevron-right"} - ] - ]) - } - > - <:indicator :let={item}> - <.heroicon name={item.data.meta.indicator} /> - - -
- """ - end -end diff --git a/e2e/lib/e2e_web/live/accordion_play_live.ex b/e2e/lib/e2e_web/live/accordion_play_live.ex deleted file mode 100644 index c3908444..00000000 --- a/e2e/lib/e2e_web/live/accordion_play_live.ex +++ /dev/null @@ -1,173 +0,0 @@ -defmodule E2eWeb.AccordionPlayLive do - use E2eWeb, :live_view - - defp accordion_items(controls) do - all_disabled = Map.get(controls, :disabled, false) - - Corex.Content.new([ - [ - id: "lorem", - trigger: "Lorem ipsum dolor sit amet", - content: "Consectetur adipiscing elit.", - disabled: all_disabled || Map.get(controls, :disabled_lorem, false) - ], - [ - id: "ipsum", - trigger: "Duis dictum gravida odio ac pharetra?", - content: "Nullam eget vestibulum ligula.", - disabled: all_disabled - ], - [ - id: "dolor", - trigger: "Donec condimentum ex mi", - content: "Congue molestie ipsum gravida a.", - disabled: all_disabled - ] - ]) - end - - @impl true - def mount(_params, _session, socket) do - controls = %{ - disabled: false, - disabled_lorem: false, - orientation: "vertical", - collapsible: true, - multiple: true, - dir: "ltr" - } - - socket = - socket - |> assign(:controls, controls) - |> assign(:items, accordion_items(controls)) - - {:ok, socket} - end - - @impl true - def handle_event( - "control_changed", - %{"checked" => checked, "id" => id}, - socket - ) do - {:noreply, update_control(socket, id, checked)} - end - - def handle_event( - "control_changed", - %{"value" => [value], "id" => id}, - socket - ) do - {:noreply, update_control(socket, id, value)} - end - - defp update_control(socket, "disabled", checked) do - new_controls = Map.put(socket.assigns.controls, :disabled, checked) - - socket - |> assign(:controls, new_controls) - |> assign(:items, accordion_items(new_controls)) - end - - defp update_control(socket, "disabled_lorem", checked) do - new_controls = Map.put(socket.assigns.controls, :disabled_lorem, checked) - - socket - |> assign(:controls, new_controls) - |> assign(:items, accordion_items(new_controls)) - end - - defp update_control(socket, "orientation", value) do - update(socket, :controls, &Map.put(&1, :orientation, value)) - end - - defp update_control(socket, "dir", value) do - update(socket, :controls, &Map.put(&1, :dir, value)) - end - - defp update_control(socket, _unknown, _checked), do: socket - - @impl true - def render(assigns) do - ~H""" - - <.layout_heading> - <:title>Accordion - <:subtitle>Playground - - -
- <.switch - class="switch" - id="disabled" - on_checked_change="control_changed" - > - <:label>Disable All - - - <.switch - class="switch" - id="disabled_lorem" - on_checked_change="control_changed" - > - <:label>Disable “Lorem” Item - - - <.toggle_group - class="toggle-group" - id="dir" - on_value_change="control_changed" - multiple={false} - deselectable={false} - value={[@controls.dir]} - > - <:item value="ltr"> - LTR - - - <:item value="rtl"> - RTL - - - - <.toggle_group - class="toggle-group" - id="orientation" - on_value_change="control_changed" - multiple={false} - deselectable={false} - value={["vertical"]} - > - <:item value="horizontal"> - <.heroicon name="hero-arrows-right-left" /> - - - <:item value="vertical"> - <.heroicon name="hero-arrows-up-down" /> - - -
- - <.accordion - id="my-accordion" - class="accordion" - items={@items} - multiple - orientation={@controls.orientation} - dir={@controls.dir} - > - <:indicator :let={_item}> - <.heroicon name="hero-chevron-right" /> - - -
- """ - end -end diff --git a/e2e/lib/e2e_web/live/action_live.ex b/e2e/lib/e2e_web/live/action_live.ex deleted file mode 100644 index 29b8ceb5..00000000 --- a/e2e/lib/e2e_web/live/action_live.ex +++ /dev/null @@ -1,85 +0,0 @@ -defmodule E2eWeb.ActionLive do - use E2eWeb, :live_view - - def mount(_params, _session, socket) do - {:ok, socket} - end - - def render(assigns) do - ~H""" - - <.layout_heading> - <:title>Action - <:subtitle>Live View - -

Anatomy

-
-
- <.action class="button">Text - <.action class="button"> - Text and SVG - - - <.action class="button button--square" aria_label="Button text"> - - - <.action class="button button--square" aria_label="Button text">B -
-
- -

Color

-
-
- <.action class="button">Text - <.action class="button button--accent">Text - <.action class="button button--brand">Text - <.action class="button button--alert">Text - <.action class="button button--info">Text - <.action class="button button--success">Text -
-
- -

Size

-
-
- <.action class="button button--sm">Button SM - <.action class="button">Button MD - <.action class="button button--lg">Button LG - <.action class="button button--xl">Button XL -
-
- -

Shape

-
-
- <.action class="button button--square" aria_label="Square button"> - - - <.action class="button button--square" aria_label="Square button">B - <.action class="button button--circle" aria_label="Circle button"> - - - <.action class="button button--circle" aria_label="Circle button">B -
-
- -

Disabled

-
-
- <.action class="button" disabled>Text - <.action class="button button--accent" disabled>Text - <.action class="button button--square" aria_label="Disabled" disabled> - - -
-
-
- """ - end -end diff --git a/e2e/lib/e2e_web/live/admin_live/form.ex b/e2e/lib/e2e_web/live/admin_live/form.ex index 945205ff..47259aab 100644 --- a/e2e/lib/e2e_web/live/admin_live/form.ex +++ b/e2e/lib/e2e_web/live/admin_live/form.ex @@ -11,14 +11,13 @@ defmodule E2eWeb.AdminLive.Form do flash={@flash} mode={@mode} theme={@theme} - locale={@locale} - current_path={@current_path} + path={@path} > - <.layout_heading> + <.layout_heading class="layout-heading"> <:title>{@page_title} <:subtitle>Use this form to manage admin records in your database. <:actions> - <.navigate to={return_path(@return_to, @admin, @locale)} type="navigate" class="button"> + <.navigate to={return_path(@return_to, @admin)} type="navigate" class="button button--sm"> <.heroicon name="hero-arrow-left" class="icon" /> Cancel @@ -26,7 +25,7 @@ defmodule E2eWeb.AdminLive.Form do <.form for={@form} - id={get_form_id(@form)} + id={@form.id} phx-change="validate" phx-submit="save" > @@ -39,8 +38,9 @@ defmodule E2eWeb.AdminLive.Form do <.select - class="select" + class="select max-w-none" field={@form[:country]} + deselectable translation={%Corex.Select.Translation{placeholder: gettext("Select a country")}} items={[ %{label: "France", id: "fra"}, @@ -60,7 +60,7 @@ defmodule E2eWeb.AdminLive.Form do - <.date_picker field={@form[:birth_date]} class="date-picker" controlled> + <.date_picker field={@form[:birth_date]} class="date-picker max-w-none"> <:label>Select a date <:trigger> <.heroicon name="hero-calendar" class="icon" /> @@ -76,7 +76,7 @@ defmodule E2eWeb.AdminLive.Form do {msg} - <.signature_pad field={@form[:signature]} class="signature-pad"> + <.signature_pad field={@form[:signature]} class="signature-pad max-w-none"> <:label>Sign here <:clear_trigger> <.heroicon name="hero-x-mark" /> @@ -91,7 +91,7 @@ defmodule E2eWeb.AdminLive.Form do Accept the terms <:indicator> - <.heroicon name="hero-check" class="data-checked" /> + <.heroicon name="hero-check" /> <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> @@ -99,8 +99,8 @@ defmodule E2eWeb.AdminLive.Form do -