{
"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"
]
}