Skip to main content

priv/registry/carousel.json

{
  "files": [
    {
      "content": "defmodule Shadix.Components.Carousel do\n  @moduledoc \"\"\"\n  A horizontal carousel built on native CSS scroll-snap and a small LiveView hook.\n\n  Unlike the React original (which wraps [Embla](https://www.embla-carousel.com/)),\n  this v1 is a pure CSS scroll-snap carousel: the content track is an\n  `overflow-x-auto` flex row with `snap-x snap-mandatory`, and each item is a\n  full-width `snap-start` child. Scrolling, momentum, and snapping are entirely\n  native to the browser, so there is no JS layout/animation loop.\n\n  The `ShadixCarousel` hook (assets/ts/carousel.ts) only wires the\n  previous/next buttons: clicking them scrolls the content element by one item\n  (its `clientWidth`) left or right with smooth behaviour, and the hook toggles\n  the buttons' `disabled` state at the start/end of the track (and on scroll /\n  resize).\n\n  ## Simplifications vs. the original\n\n    * **Horizontal only.** No `orientation=\"vertical\"`.\n    * **No drag/pointer physics.** You can still drag/flick on touch devices via\n      native scrolling, but there is no Embla-style mouse-drag inertia.\n    * **No autoplay**, no loop, no programmatic `setApi`/`opts`/`plugins`.\n    * **One-item paging.** Prev/next scroll by exactly one viewport width\n      (one full-basis item) rather than honouring multi-item `slidesToScroll`.\n    * **Keyboard arrows** are not bound (the React version listens for\n      Left/Right); rely on the focusable scroll container instead.\n\n  The content track and buttons are linked by `data-carousel-prev` /\n  `data-carousel-next` markers and a `data-carousel-content` marker so the hook\n  can find them from the root wrapper without ids.\n  \"\"\"\n  use Phoenix.Component\n\n  import Shadix.Cn\n\n  @doc \"\"\"\n  Renders the carousel root: a `relative` wrapper carrying the `ShadixCarousel`\n  hook.\n\n  Compose `carousel_content/1` (with `carousel_item/1` children) and the\n  `carousel_previous/1` / `carousel_next/1` buttons inside it. The hook finds\n  the content track and buttons by their `data-carousel-*` markers, so they may\n  appear anywhere within this wrapper.\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def carousel(assigns) do\n    ~H\"\"\"\n    <div\n      id={@id}\n      phx-hook=\"ShadixCarousel\"\n      data-slot=\"carousel\"\n      role=\"region\"\n      aria-roledescription=\"carousel\"\n      class={cn([\"relative\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  The scrollable track. A horizontal flex row with mandatory x scroll-snap and\n  a hidden scrollbar.\n\n  Carries `data-carousel-content` so the `ShadixCarousel` hook can scroll it by\n  one item when the prev/next buttons are clicked.\n  \"\"\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def carousel_content(assigns) do\n    ~H\"\"\"\n    <div\n      data-slot=\"carousel-content\"\n      data-carousel-content\n      tabindex=\"0\"\n      role=\"group\"\n      aria-roledescription=\"carousel slides\"\n      aria-label=\"Slides\"\n      class={\n        cn([\n          \"flex overflow-x-auto snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n          @class\n        ])\n      }\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  A single slide. Full-basis, non-growing/shrinking, snapping to its start edge.\n  \"\"\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def carousel_item(assigns) do\n    ~H\"\"\"\n    <div\n      data-slot=\"carousel-item\"\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      class={cn([\"min-w-0 shrink-0 grow-0 basis-full snap-start\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  The \"previous\" control: a round, absolutely-positioned icon button.\n\n  Carries `data-carousel-prev`; the hook wires its click to scroll the content\n  back by one item and toggles its `disabled` state at the start of the track.\n  \"\"\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n\n  def carousel_previous(assigns) do\n    ~H\"\"\"\n    <button\n      type=\"button\"\n      data-slot=\"carousel-previous\"\n      data-carousel-prev\n      aria-label=\"Previous slide\"\n      class={\n        cn([\n          \"absolute top-1/2 -left-12 size-8 -translate-y-1/2 inline-flex items-center justify-center rounded-full border border-input bg-background shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n          @class\n        ])\n      }\n      {@rest}\n    >\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width=\"24\"\n        height=\"24\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-width=\"2\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        aria-hidden=\"true\"\n      >\n        <path d=\"m15 18-6-6 6-6\" />\n      </svg>\n    </button>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  The \"next\" control: a round, absolutely-positioned icon button.\n\n  Carries `data-carousel-next`; the hook wires its click to scroll the content\n  forward by one item and toggles its `disabled` state at the end of the track.\n  \"\"\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n\n  def carousel_next(assigns) do\n    ~H\"\"\"\n    <button\n      type=\"button\"\n      data-slot=\"carousel-next\"\n      data-carousel-next\n      aria-label=\"Next slide\"\n      class={\n        cn([\n          \"absolute top-1/2 -right-12 size-8 -translate-y-1/2 inline-flex items-center justify-center rounded-full border border-input bg-background shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n          @class\n        ])\n      }\n      {@rest}\n    >\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width=\"24\"\n        height=\"24\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-width=\"2\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        aria-hidden=\"true\"\n      >\n        <path d=\"m9 18 6-6-6-6\" />\n      </svg>\n    </button>\n    \"\"\"\n  end\nend\n",
      "path": "carousel.ex"
    }
  ],
  "hooks": [
    {
      "content": "interface CarouselHook {\n  el: HTMLElement;\n  mounted(): void;\n  destroyed(): void;\n}\n\nexport const ShadixCarousel = {\n  mounted(this: CarouselHook) {\n    const root = this.el;\n    const content = root.querySelector<HTMLElement>(\"[data-carousel-content]\");\n    const prev = root.querySelector<HTMLButtonElement>(\"[data-carousel-prev]\");\n    const next = root.querySelector<HTMLButtonElement>(\"[data-carousel-next]\");\n\n    if (!content) return;\n\n    // One \"page\" is the visible width of the track (one full-basis item).\n    const scrollByPage = (dir: -1 | 1) => {\n      content.scrollBy({ left: dir * content.clientWidth, behavior: \"smooth\" });\n    };\n\n    // Disable prev/next at the ends. A 1px tolerance avoids flicker from\n    // sub-pixel scroll positions after snapping.\n    const updateButtons = () => {\n      const atStart = content.scrollLeft <= 1;\n      const maxScroll = content.scrollWidth - content.clientWidth;\n      const atEnd = content.scrollLeft >= maxScroll - 1;\n      if (prev) prev.disabled = atStart;\n      if (next) next.disabled = atEnd;\n    };\n\n    const onPrev = () => scrollByPage(-1);\n    const onNext = () => scrollByPage(1);\n\n    // Match the React original: Left/Right arrows page the carousel from\n    // anywhere inside the region. Ignore when focus is in a text field so we\n    // don't hijack caret movement.\n    const onKeyDown = (event: KeyboardEvent) => {\n      const target = event.target as HTMLElement | null;\n      if (\n        target &&\n        (target.isContentEditable ||\n          /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))\n      ) {\n        return;\n      }\n      if (event.key === \"ArrowLeft\") {\n        event.preventDefault();\n        scrollByPage(-1);\n      } else if (event.key === \"ArrowRight\") {\n        event.preventDefault();\n        scrollByPage(1);\n      }\n    };\n\n    prev?.addEventListener(\"click\", onPrev);\n    next?.addEventListener(\"click\", onNext);\n    root.addEventListener(\"keydown\", onKeyDown);\n    content.addEventListener(\"scroll\", updateButtons, { passive: true });\n\n    // Recompute on resize, since clientWidth and the end position change.\n    const resizeObserver = new ResizeObserver(updateButtons);\n    resizeObserver.observe(content);\n\n    updateButtons();\n\n    (root as unknown as { _cleanup?: () => void })._cleanup = () => {\n      prev?.removeEventListener(\"click\", onPrev);\n      next?.removeEventListener(\"click\", onNext);\n      root.removeEventListener(\"keydown\", onKeyDown);\n      content.removeEventListener(\"scroll\", updateButtons);\n      resizeObserver.disconnect();\n    };\n  },\n\n  destroyed(this: CarouselHook) {\n    (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n  },\n};\n",
      "name": "ShadixCarousel",
      "path": "carousel.ts"
    }
  ],
  "name": "carousel",
  "npm_deps": [],
  "registry_deps": [
    "cn"
  ]
}