defmodule Corex.Carousel do @moduledoc ~S''' Phoenix implementation of [Zag.js Carousel](https://zagjs.com/components/react/carousel). ## Examples ### Basic with image URLs ```heex <.carousel id="car" items={["/images/beach.jpg", "/images/fall.jpg", "/images/sand.jpg"]} class="carousel"> <:prev_trigger> <:next_trigger> ``` Items can be URLs (strings) or maps with `:url` and optional `:alt` keys. ## API Imperative helpers target the carousel by DOM `id` on the hook root (the same `id` you pass to `carousel/1`). - **Client** - `play/1`, `pause/1`, `scroll_next/1`, `scroll_prev/1`, and `scroll_next/2`, `scroll_prev/2` with optional `instant` (`JS.dispatch` to `corex:carousel:*`). - **Server** - `play/2`, `pause/2`, `scroll_next/2`, `scroll_prev/2`, and `scroll_next/3`, `scroll_prev/3` with optional `instant` (`push_event` consumed by the hook). ## Events **From the client**, dispatch `CustomEvent`s on the hook root (`#your-id`): | Type | Purpose | |------|---------| | `corex:carousel:play` | Start or resume autoplay | | `corex:carousel:pause` | Pause autoplay | | `corex:carousel:scroll-next` | Next page; optional `detail.instant` boolean | | `corex:carousel:scroll-prev` | Previous page; optional `detail.instant` boolean | **From LiveView**, `push_event` names: `carousel_play`, `carousel_pause`, `carousel_scroll_next`, `carousel_scroll_prev` with payload `%{"id" => ...}` and optional `"instant"` for scroll events. ## Styling Use data attributes to target elements: ```css [data-scope="carousel"][data-part="root"] {} [data-scope="carousel"][data-part="control"] {} [data-scope="carousel"][data-part="item-group"] {} [data-scope="carousel"][data-part="item"] {} [data-scope="carousel"][data-part="prev-trigger"] {} [data-scope="carousel"][data-part="next-trigger"] {} [data-scope="carousel"][data-part="indicator-group"] {} [data-scope="carousel"][data-part="indicator"] {} ``` If you wish to use the default Corex styling, you can use the class `carousel` on the component. This requires to install `Mix.Tasks.Corex.Design` first and import the component css file. ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/carousel.css"; ``` You can then use modifiers ```heex <.carousel class="carousel carousel--accent carousel--lg" items={[]}> ``` ''' @doc type: :component use Phoenix.Component alias Corex.Carousel.Anatomy.{ Control, Indicator, IndicatorGroup, Item, ItemGroup, NextTrigger, PrevTrigger, Props, Root } alias Corex.Carousel.Connect alias Corex.Carousel.Utils alias Phoenix.LiveView alias Phoenix.LiveView.JS attr(:id, :string, required: false) attr(:aria_label, :string, default: nil) attr(:items, :list, default: nil, doc: "List of image URLs (strings) or maps with :url and optional :alt. Omit in compound mode; use `item_count` when children do not pass through `items`." ) attr(:item_count, :integer, default: nil, doc: "When set, overrides the slide count used for the hook and compound context (use in compound mode without `items`)." ) attr(:page, :integer, default: 0) attr(:controlled, :boolean, default: false) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"]) attr(:orientation, :string, default: "horizontal", values: ["horizontal", "vertical"]) attr(:slides_per_page, :integer, default: 1) attr(:slides_per_move, :any, default: nil, doc: "Number of slides to move per step, or \"auto\"" ) attr(:loop, :boolean, default: false) attr(:autoplay, :boolean, default: false) attr(:autoplay_delay, :integer, default: 4000) attr(:allow_mouse_drag, :boolean, default: false) attr(:spacing, :string, default: "0px") attr(:padding, :string, default: nil) attr(:in_view_threshold, :float, default: 0.6) attr(:snap_type, :string, default: "mandatory", values: ["proximity", "mandatory"]) attr(:auto_size, :boolean, default: false) attr(:on_page_change, :string, default: nil) attr(:on_page_change_client, :string, default: nil) attr(:compound, :boolean, default: false, doc: "Enable compound mode with :let={ctx} and carousel_root, carousel_item_group, carousel_item, carousel_control, carousel_prev_trigger, carousel_next_trigger, carousel_indicator_group, carousel_indicator." ) attr(:rest, :global) slot(:inner_block, required: false, doc: "Compound mode. Use compound and :let={ctx}." ) slot :prev_trigger, required: false do attr(:class, :string, required: false) end slot :next_trigger, required: false do attr(:class, :string, required: false) end def carousel(assigns) do assigns = Utils.merge_attr_defaults(assigns) {items, slide_count, total_pages, prev_disabled, next_disabled, slides_per_page} = Utils.compute_slide_metrics(assigns) assigns = assigns |> assign_new(:id, fn -> "carousel-#{System.unique_integer([:positive])}" end) |> assign_new(:dir, fn -> "ltr" end) |> assign(:items, items) |> assign(:slide_count, slide_count) |> assign(:total_pages, total_pages) |> assign(:prev_disabled, prev_disabled) |> assign(:next_disabled, next_disabled) |> assign(:slides_per_page, slides_per_page) ctx = %{ id: assigns.id, items: assigns.items, slide_count: assigns.slide_count, total_pages: assigns.total_pages, page: assigns.page, prev_disabled: assigns.prev_disabled, next_disabled: assigns.next_disabled, orientation: assigns.orientation, dir: assigns.dir, slides_per_page: assigns.slides_per_page, spacing: assigns.spacing, aria_label: assigns.aria_label } assigns = assign(assigns, :ctx, ctx) ~H"""
{if @compound do render_slot(@inner_block, @ctx) end}
{Map.get(item,
""" end attr(:ctx, :map, required: true) attr(:rest, :global) slot(:inner_block, required: true) def carousel_root(assigns) do root = %Root{ id: assigns.ctx.id, dir: assigns.ctx.dir, orientation: assigns.ctx.orientation, slides_per_page: assigns.ctx.slides_per_page, spacing: assigns.ctx.spacing || "0px", aria_label: assigns.ctx.aria_label } assigns = assign(assigns, :root, root) ~H"""
{render_slot(@inner_block)}
""" end attr(:ctx, :map, required: true) attr(:rest, :global) slot(:inner_block, required: true) def carousel_item_group(assigns) do ig = %ItemGroup{ id: assigns.ctx.id, orientation: assigns.ctx.orientation, dir: assigns.ctx.dir } assigns = assign(assigns, :ig, ig) ~H"""
{render_slot(@inner_block)}
""" end attr(:ctx, :map, required: true) attr(:index, :integer, required: true) attr(:rest, :global) slot(:inner_block, required: true) def carousel_item(assigns) do item = %Item{ id: assigns.ctx.id, index: assigns.index, orientation: assigns.ctx.orientation, slide_count: assigns.ctx.slide_count } assigns = assign(assigns, :item, item) ~H"""
{render_slot(@inner_block)}
""" end attr(:ctx, :map, required: true) attr(:rest, :global) slot(:inner_block, required: true) def carousel_control(assigns) do ctl = %Control{id: assigns.ctx.id, orientation: assigns.ctx.orientation} assigns = assign(assigns, :ctl, ctl) ~H"""
{render_slot(@inner_block)}
""" end attr(:ctx, :map, required: true) attr(:rest, :global) slot(:inner_block, required: true) def carousel_prev_trigger(assigns) do pt = %PrevTrigger{id: assigns.ctx.id, disabled: assigns.ctx.prev_disabled} assigns = assign(assigns, :pt, pt) ~H""" """ end attr(:ctx, :map, required: true) attr(:rest, :global) slot(:inner_block, required: true) def carousel_next_trigger(assigns) do nt = %NextTrigger{id: assigns.ctx.id, disabled: assigns.ctx.next_disabled} assigns = assign(assigns, :nt, nt) ~H""" """ end attr(:ctx, :map, required: true) attr(:rest, :global) slot(:inner_block, required: true) def carousel_indicator_group(assigns) do g = %IndicatorGroup{ id: assigns.ctx.id, orientation: assigns.ctx.orientation, dir: assigns.ctx.dir } assigns = assign(assigns, :g, g) ~H"""
{render_slot(@inner_block)}
""" end attr(:ctx, :map, required: true) attr(:index, :integer, required: true) attr(:rest, :global) slot(:inner_block, required: false) def carousel_indicator(assigns) do ind = %Indicator{ id: assigns.ctx.id, index: assigns.index, orientation: assigns.ctx.orientation, dir: assigns.ctx.dir, page: assigns.ctx.page } assigns = assign(assigns, :ind, ind) ~H""" """ end @doc type: :api @doc """ Starts or resumes carousel autoplay from the client. Returns a `Phoenix.LiveView.JS` command. """ def play(carousel_id) when is_binary(carousel_id) do JS.dispatch("corex:carousel:play", to: "##{carousel_id}", detail: %{}, bubbles: false) end @doc type: :api @doc """ Pauses carousel autoplay from the client. """ def pause(carousel_id) when is_binary(carousel_id) do JS.dispatch("corex:carousel:pause", to: "##{carousel_id}", detail: %{}, bubbles: false) end @doc type: :api def play(socket, carousel_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(carousel_id) do LiveView.push_event(socket, "carousel_play", %{"id" => carousel_id}) end @doc type: :api def pause(socket, carousel_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(carousel_id) do LiveView.push_event(socket, "carousel_pause", %{"id" => carousel_id}) end @doc type: :api @doc """ Scrolls to the next page from the client. """ def scroll_next(carousel_id) when is_binary(carousel_id), do: scroll_next(carousel_id, false) @doc type: :api @doc """ Scrolls to the next page from the client. Pass `true` for `instant` to skip animation. """ def scroll_next(carousel_id, instant) when is_binary(carousel_id) and is_boolean(instant) do JS.dispatch("corex:carousel:scroll-next", to: "##{carousel_id}", detail: scroll_detail(instant), bubbles: false ) end @doc type: :api def scroll_next(socket, carousel_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(carousel_id) do scroll_next(socket, carousel_id, false) end @doc type: :api def scroll_next(socket, carousel_id, instant) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(carousel_id) and is_boolean(instant) do LiveView.push_event(socket, "carousel_scroll_next", scroll_payload(carousel_id, instant)) end @doc type: :api @doc """ Scrolls to the previous page from the client. """ def scroll_prev(carousel_id) when is_binary(carousel_id), do: scroll_prev(carousel_id, false) @doc type: :api def scroll_prev(carousel_id, instant) when is_binary(carousel_id) and is_boolean(instant) do JS.dispatch("corex:carousel:scroll-prev", to: "##{carousel_id}", detail: scroll_detail(instant), bubbles: false ) end @doc type: :api def scroll_prev(socket, carousel_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(carousel_id) do scroll_prev(socket, carousel_id, false) end @doc type: :api def scroll_prev(socket, carousel_id, instant) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(carousel_id) and is_boolean(instant) do LiveView.push_event(socket, "carousel_scroll_prev", scroll_payload(carousel_id, instant)) end defp scroll_detail(false), do: %{} defp scroll_detail(true), do: %{instant: true} defp scroll_payload(carousel_id, instant) do base = %{"id" => carousel_id} if instant, do: Map.put(base, "instant", true), else: base end end