defmodule Corex.Carousel do
@moduledoc ~S'''
Phoenix implementation of [Zag.js Carousel](https://zagjs.com/components/react/carousel).
## Anatomy
| Goal | API |
| ---- | --- |
| Image gallery | `items={[Corex.Image.new("/images/a.jpg", alt: "A"), ...]}` — renders `` per slide |
| Custom slides | `items={@posts}` + `<:item :let={post}>` — your markup per slide |
| Full structure | `compound` + `carousel_*` subcomponents |
## Items
Pass `items` for simple-mode slides. Image galleries use [`Corex.Image`](Corex.Image.html) — see
[`Corex.Image.new/2`](Corex.Image.html#new/2). Custom slides use any list plus the `<:item>` slot.
| `items` | `<:item>` slot | Result |
| ------- | -------------- | ------ |
| `[%Corex.Image{}, ...]` | omitted | `` per slide |
| any list | `<:item :let={item}>` | your markup via `render_slot/2` |
| — | — | compound mode: set `item_count` and use `carousel_item` |
Without `<:item>`, every entry must be `%Corex.Image{}`; other values raise at render time.
Slides may include links and other controls; off-view slides are marked `inert` on the client so they stay out of the tab order.
### Images
```heex
<.carousel
items={[
Corex.Image.new("/images/beach.jpg", alt: "Beach"),
Corex.Image.new("/images/fall.jpg", alt: "Fall"),
Corex.Image.new("/images/sand.jpg", alt: "Sand"),
Corex.Image.new("/images/star.jpg", alt: "Star"),
Corex.Image.new("/images/winter.jpg", alt: "Winter")
]}
class="carousel"
>
<:prev_trigger><.heroicon name="hero-arrow-left" />
<:next_trigger><.heroicon name="hero-arrow-right" />
```
### Custom content
```heex
<.carousel items={@posts} class="carousel">
<:item :let={post}>
{post.title}
{post.description}
<.navigate to="#" class="link">Read more
<:prev_trigger><.heroicon name="hero-arrow-left" />
<:next_trigger><.heroicon name="hero-arrow-right" />
```
### Compound
```heex
<.carousel compound :let={ctx} item_count={2} class="carousel">
<.carousel_root ctx={ctx}>
<.carousel_item_group ctx={ctx}>
<.carousel_item ctx={ctx} index={0}>First slide
<.carousel_item ctx={ctx} index={1}>Second slide
<.carousel_control ctx={ctx}>
<.carousel_prev_trigger ctx={ctx}><.heroicon name="hero-arrow-left" />
<.carousel_indicator_group ctx={ctx}>
<.carousel_indicator ctx={ctx} index={0} />
<.carousel_indicator ctx={ctx} index={1} />
<.carousel_next_trigger ctx={ctx}><.heroicon name="hero-arrow-right" />
```
## API
Requires a stable `id` on `<.carousel>`.
| Function | Action | Returns |
| -------- | ------ | ------- |
| [`play/1`](#play/1) | Start or resume autoplay (client) | `%Phoenix.LiveView.JS{}` |
| [`play/2`](#play/2) | Start or resume autoplay (server) | `socket` |
| [`pause/1`](#pause/1) | Pause autoplay (client) | `%Phoenix.LiveView.JS{}` |
| [`pause/2`](#pause/2) | Pause autoplay (server) | `socket` |
| [`scroll_next/1`](#scroll_next/1) | Next page (client) | `%Phoenix.LiveView.JS{}` |
| [`scroll_next/2`](#scroll_next/2) | Next page with `instant` (client) | `%Phoenix.LiveView.JS{}` |
| [`scroll_next/3`](#scroll_next/3) | Next page (server) | `socket` |
| [`scroll_prev/1`](#scroll_prev/1) | Previous page (client) | `%Phoenix.LiveView.JS{}` |
| [`scroll_prev/2`](#scroll_prev/2) | Previous page with `instant` (client) | `%Phoenix.LiveView.JS{}` |
| [`scroll_prev/3`](#scroll_prev/3) | Previous page (server) | `socket` |
## Events
Pick an event name and pass it to `on_*` on `<.carousel>`.
### Server events
| Event | When | Payload |
| ----- | ---- | ------- |
| `on_page_change="carousel_page_changed"` | Active page changes | `%{"id" => id, "page" => page, "pageSnapPoint" => snap}` |
### on_page_change
```heex
<.carousel
class="carousel"
on_page_change="carousel_page_changed"
items={[
Corex.Image.new("/images/beach.jpg", alt: "Beach"),
Corex.Image.new("/images/fall.jpg", alt: "Fall"),
Corex.Image.new("/images/sand.jpg", alt: "Sand"),
Corex.Image.new("/images/star.jpg", alt: "Star"),
Corex.Image.new("/images/winter.jpg", alt: "Winter")
]}
>
<:prev_trigger><.heroicon name="hero-arrow-left" />
<:next_trigger><.heroicon name="hero-arrow-right" />
```
```elixir
def handle_event("carousel_page_changed", %{"id" => _id, "page" => page}, socket) do
{:noreply, assign(socket, :carousel_page, page)}
end
```
### Client events
| Event | When | `event.detail` |
| ----- | ---- | -------------- |
| `on_page_change_client="carousel-page-changed"` | Active page changes | `id`, `page`, `pageSnapPoint` |
### on_page_change_client
```heex
<.carousel
id="carousel-events-client"
class="carousel"
on_page_change_client="carousel-page-changed"
items={[
Corex.Image.new("/images/beach.jpg", alt: "Beach"),
Corex.Image.new("/images/fall.jpg", alt: "Fall"),
Corex.Image.new("/images/sand.jpg", alt: "Sand"),
Corex.Image.new("/images/star.jpg", alt: "Star"),
Corex.Image.new("/images/winter.jpg", alt: "Winter")
]}
>
<:prev_trigger><.heroicon name="hero-arrow-left" />
<:next_trigger><.heroicon name="hero-arrow-right" />
```
```javascript
const el = document.getElementById("carousel-events-client");
el?.addEventListener("carousel-page-changed", (e) => console.log(e.detail));
```
## Style
Target parts with `data-scope` and `data-part`, or use Corex Design: import tokens and `carousel.css`, then set `class="carousel"` on `<.carousel>`.
```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"] {}
```
```css
@import "../corex/main.css";
@import "../corex/tokens/themes/neo/light.css";
@import "../corex/components/carousel.css";
```
Stack modifiers on the host (`class` on `<.carousel>`).
### Color
| Modifier | Classes |
| -------- | ------- |
| Default | `carousel` |
| Accent | `carousel carousel--accent` |
| Brand | `carousel carousel--brand` |
| Alert | `carousel carousel--alert` |
| Info | `carousel carousel--info` |
| Success | `carousel carousel--success` |
```heex
<.carousel
items={[
Corex.Image.new("/images/beach.jpg", alt: "Beach"),
Corex.Image.new("/images/fall.jpg", alt: "Fall"),
Corex.Image.new("/images/sand.jpg", alt: "Sand"),
Corex.Image.new("/images/star.jpg", alt: "Star"),
Corex.Image.new("/images/winter.jpg", alt: "Winter")
]}
class="carousel"
>
<:prev_trigger><.heroicon name="hero-arrow-left" />
<:next_trigger><.heroicon name="hero-arrow-right" />
<.carousel
items={[
Corex.Image.new("/images/beach.jpg", alt: "Beach"),
Corex.Image.new("/images/fall.jpg", alt: "Fall"),
Corex.Image.new("/images/sand.jpg", alt: "Sand"),
Corex.Image.new("/images/star.jpg", alt: "Star"),
Corex.Image.new("/images/winter.jpg", alt: "Winter")
]}
class="carousel carousel--accent"
>
<:prev_trigger><.heroicon name="hero-arrow-left" />
<:next_trigger><.heroicon name="hero-arrow-right" />
```
### Size
Layout density for the root, control bar, prev/next triggers, autoplay control, and indicators.
| Modifier | Classes |
| -------- | ------- |
| SM | `carousel carousel--sm` |
| MD | `carousel carousel--md` |
| LG | `carousel carousel--lg` |
| XL | `carousel carousel--xl` |
```heex
<.carousel
items={[
Corex.Image.new("/images/beach.jpg", alt: "Beach"),
Corex.Image.new("/images/fall.jpg", alt: "Fall"),
Corex.Image.new("/images/sand.jpg", alt: "Sand"),
Corex.Image.new("/images/star.jpg", alt: "Star"),
Corex.Image.new("/images/winter.jpg", alt: "Winter")
]}
class="carousel carousel--sm"
>
<:prev_trigger><.heroicon name="hero-arrow-left" />
<:next_trigger><.heroicon name="hero-arrow-right" />
<.carousel
items={[
Corex.Image.new("/images/beach.jpg", alt: "Beach"),
Corex.Image.new("/images/fall.jpg", alt: "Fall"),
Corex.Image.new("/images/sand.jpg", alt: "Sand"),
Corex.Image.new("/images/star.jpg", alt: "Star"),
Corex.Image.new("/images/winter.jpg", alt: "Winter")
]}
class="carousel carousel--lg"
>
<:prev_trigger><.heroicon name="hero-arrow-left" />
<:next_trigger><.heroicon name="hero-arrow-right" />
```
### Radius
Corner radius for the slide area and prev/next triggers.
| Modifier | Classes |
| -------- | ------- |
| None | `carousel carousel--rounded-none` |
| SM | `carousel carousel--rounded-sm` |
| MD | `carousel carousel--rounded-md` |
| LG | `carousel carousel--rounded-lg` |
| XL | `carousel carousel--rounded-xl` |
| Full | `carousel carousel--rounded-full` |
```heex
<.carousel
items={[
Corex.Image.new("/images/beach.jpg", alt: "Beach"),
Corex.Image.new("/images/fall.jpg", alt: "Fall"),
Corex.Image.new("/images/sand.jpg", alt: "Sand"),
Corex.Image.new("/images/star.jpg", alt: "Star"),
Corex.Image.new("/images/winter.jpg", alt: "Winter")
]}
class="carousel carousel--rounded-md"
>
<:prev_trigger><.heroicon name="hero-arrow-left" />
<:next_trigger><.heroicon name="hero-arrow-right" />
```
'''
@doc type: :component
use Phoenix.Component
import Corex.Api.Doc
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 `%Corex.Image{}` for image slides, or arbitrary data when `<:item>` is set. 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: 1,
doc: "Active page (1-based, same as pagination; first page is 1)"
)
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
slot :item,
required: false,
doc:
"Custom markup for each slide. Use :let={item} to receive each entry from `items`. Required when `items` are not `%Corex.Image{}` structs." 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)
has_item_slot = Utils.item_slot?(assigns)
Utils.validate_items!(items, has_item_slot)
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)
|> assign(:has_item_slot, has_item_slot)
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}
<%= if @has_item_slot do %>
{render_slot(@item, item)}
<% else %>
<% end %>