From 869d362b9f311ab698ee9bc044a32a6d41b56c37 Mon Sep 17 00:00:00 2001 From: Sil Date: Sun, 27 Sep 2026 16:03:04 +0200 Subject: [PATCH 1/3] fix: keep visible carousel slides interactive --- .changeset/visible-carousel-slides.md | 5 ++ public/r/carousel.json | 7 ++- public/r/registry.json | 4 ++ registry.json | 4 ++ .../ui/carousel/carousel-visibility.ts | 53 +++++++++++++++++++ src/components/ui/carousel/carousel.astro | 18 ++++++- src/content/pages/components/carousel.mdx | 39 +++++++++++++- tests/data-slot.spec.ts | 43 +++++++++++++++ 8 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 .changeset/visible-carousel-slides.md create mode 100644 src/components/ui/carousel/carousel-visibility.ts diff --git a/.changeset/visible-carousel-slides.md b/.changeset/visible-carousel-slides.md new file mode 100644 index 000000000..9c4b1e4ba --- /dev/null +++ b/.changeset/visible-carousel-slides.md @@ -0,0 +1,5 @@ +--- +"fulldev-ui": patch +--- + +Add opt-in multi-slide carousel visibility so every visible slide remains interactive and offscreen slides stay hidden from assistive technology. diff --git a/public/r/carousel.json b/public/r/carousel.json index 8586df5fd..0dddb1122 100644 --- a/public/r/carousel.json +++ b/public/r/carousel.json @@ -13,7 +13,7 @@ "files": [ { "path": "src/components/ui/carousel/carousel.astro", - "content": "---\nimport type { HTMLAttributes } from \"astro/types\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype Props = HTMLAttributes<\"div\"> & {\n defaultIndex?: number\n orientation?: \"horizontal\" | \"vertical\"\n drag?: boolean\n loop?: boolean\n}\n\nconst {\n class: className,\n defaultIndex,\n orientation = \"horizontal\",\n drag,\n loop,\n ...props\n} = Astro.props\n---\n\n\n \n\n\n\n", + "content": "---\nimport type { HTMLAttributes } from \"astro/types\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype Props = HTMLAttributes<\"div\"> & {\n defaultIndex?: number\n orientation?: \"horizontal\" | \"vertical\"\n drag?: boolean\n loop?: boolean\n multiple?: boolean\n}\n\nconst {\n class: className,\n defaultIndex,\n orientation = \"horizontal\",\n drag,\n loop,\n multiple,\n ...props\n} = Astro.props\n---\n\n\n \n\n\n\n", "type": "registry:ui" }, { @@ -40,6 +40,11 @@ "path": "src/components/ui/carousel/index.ts", "content": "export type { CarouselController, CarouselOptions } from \"@data-slot/carousel\"\n\nexport { default as Carousel } from \"./carousel.astro\"\nexport { default as CarouselContent } from \"./carousel-content.astro\"\nexport { default as CarouselItem } from \"./carousel-item.astro\"\nexport { default as CarouselPrevious } from \"./carousel-previous.astro\"\nexport { default as CarouselNext } from \"./carousel-next.astro\"\n", "type": "registry:ui" + }, + { + "path": "src/components/ui/carousel/carousel-visibility.ts", + "content": "/** Keep the visible portion of a multi-slide carousel accessible. */\nexport function observeCarouselVisibility(root: HTMLElement) {\n const content = root.querySelector(\n '[data-slot=\"carousel-content\"]'\n )\n if (!content) return () => {}\n\n let frame: number | undefined\n const update = () => {\n const viewport = content.getBoundingClientRect()\n const vertical = root.dataset.orientation === \"vertical\"\n for (const item of content.children) {\n if (!(item instanceof HTMLElement)) continue\n if (item.dataset.slot !== \"carousel-item\") continue\n resize.observe(item)\n const bounds = item.getBoundingClientRect()\n const visible = vertical\n ? bounds.bottom > viewport.top + 1 && bounds.top < viewport.bottom - 1\n : bounds.right > viewport.left + 1 && bounds.left < viewport.right - 1\n if (item.inert === visible) item.inert = !visible\n const hidden = String(!visible)\n if (item.getAttribute(\"aria-hidden\") !== hidden)\n item.setAttribute(\"aria-hidden\", hidden)\n }\n }\n const schedule = () => {\n frame ??= requestAnimationFrame(() => {\n frame = undefined\n update()\n })\n }\n\n // The single-slide primitive updates these attributes after scroll settles.\n // Observe that update as well as geometry so visible links stay operable.\n const mutation = new MutationObserver(update)\n mutation.observe(content, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: [\"inert\", \"aria-hidden\"],\n })\n const resize = new ResizeObserver(schedule)\n resize.observe(content)\n content.addEventListener(\"scroll\", schedule, { passive: true })\n update()\n\n return () => {\n mutation.disconnect()\n resize.disconnect()\n content.removeEventListener(\"scroll\", schedule)\n if (frame !== undefined) cancelAnimationFrame(frame)\n }\n}\n", + "type": "registry:component" } ], "docs": "https://ui.full.dev/components/carousel/", diff --git a/public/r/registry.json b/public/r/registry.json index 6ef55485b..d6a99ae48 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -1115,6 +1115,10 @@ { "path": "src/components/ui/carousel/index.ts", "type": "registry:ui" + }, + { + "path": "src/components/ui/carousel/carousel-visibility.ts", + "type": "registry:component" } ] }, diff --git a/registry.json b/registry.json index 673508033..8fd31aea4 100644 --- a/registry.json +++ b/registry.json @@ -1010,6 +1010,10 @@ { "path": "src/components/ui/carousel/index.ts", "type": "registry:ui" + }, + { + "path": "src/components/ui/carousel/carousel-visibility.ts", + "type": "registry:component" } ] }, diff --git a/src/components/ui/carousel/carousel-visibility.ts b/src/components/ui/carousel/carousel-visibility.ts new file mode 100644 index 000000000..f77df123b --- /dev/null +++ b/src/components/ui/carousel/carousel-visibility.ts @@ -0,0 +1,53 @@ +/** Keep the visible portion of a multi-slide carousel accessible. */ +export function observeCarouselVisibility(root: HTMLElement) { + const content = root.querySelector( + '[data-slot="carousel-content"]' + ) + if (!content) return () => {} + + let frame: number | undefined + const update = () => { + const viewport = content.getBoundingClientRect() + const vertical = root.dataset.orientation === "vertical" + for (const item of content.children) { + if (!(item instanceof HTMLElement)) continue + if (item.dataset.slot !== "carousel-item") continue + resize.observe(item) + const bounds = item.getBoundingClientRect() + const visible = vertical + ? bounds.bottom > viewport.top + 1 && bounds.top < viewport.bottom - 1 + : bounds.right > viewport.left + 1 && bounds.left < viewport.right - 1 + if (item.inert === visible) item.inert = !visible + const hidden = String(!visible) + if (item.getAttribute("aria-hidden") !== hidden) + item.setAttribute("aria-hidden", hidden) + } + } + const schedule = () => { + frame ??= requestAnimationFrame(() => { + frame = undefined + update() + }) + } + + // The single-slide primitive updates these attributes after scroll settles. + // Observe that update as well as geometry so visible links stay operable. + const mutation = new MutationObserver(update) + mutation.observe(content, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["inert", "aria-hidden"], + }) + const resize = new ResizeObserver(schedule) + resize.observe(content) + content.addEventListener("scroll", schedule, { passive: true }) + update() + + return () => { + mutation.disconnect() + resize.disconnect() + content.removeEventListener("scroll", schedule) + if (frame !== undefined) cancelAnimationFrame(frame) + } +} diff --git a/src/components/ui/carousel/carousel.astro b/src/components/ui/carousel/carousel.astro index 428b78379..376f4ea05 100644 --- a/src/components/ui/carousel/carousel.astro +++ b/src/components/ui/carousel/carousel.astro @@ -8,6 +8,7 @@ type Props = HTMLAttributes<"div"> & { orientation?: "horizontal" | "vertical" drag?: boolean loop?: boolean + multiple?: boolean } const { @@ -16,6 +17,7 @@ const { orientation = "horizontal", drag, loop, + multiple, ...props } = Astro.props --- @@ -26,6 +28,7 @@ const { data-orientation={orientation} data-drag={drag} data-loop={loop} + data-multiple={multiple} class={cn("group/carousel relative", className)} {...props} > @@ -35,12 +38,25 @@ const { diff --git a/src/content/pages/components/carousel.mdx b/src/content/pages/components/carousel.mdx index 2f8ad463c..1acd29835 100644 --- a/src/content/pages/components/carousel.mdx +++ b/src/content/pages/components/carousel.mdx @@ -51,7 +51,7 @@ The carousel uses the primitive. It supports native scrolling, keyboard navigation, optional pointer dragging, looped navigation, and programmatic navigation through DOM events. -The carousel shows one slide at a time. `CarouselItem` components must be direct +By default, the carousel shows one slide at a time. `CarouselItem` components must be direct children of `CarouselContent`. Inactive slides are marked with `aria-hidden` and `inert` so their content leaves the accessibility tree and tab order. @@ -220,3 +220,40 @@ See the [GitHub source code](https://github.com/fulldotdev/ui/tree/main/src/comp for component props and the [@data-slot/carousel README](https://github.com/bejamas/data-slot/tree/main/packages/carousel) for controller methods, events, keyboard behavior, and accessibility details. + +## Multiple visible slides + +Use `multiple` when CSS sizes show more than one slide. Visible slides remain +interactive and available to assistive technology. Offscreen slides stay inert. +The default single-slide behavior is unchanged. + +```astro live props={{ name: 'carousel-multiple' }} +--- +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel" +--- + + + + { + [1, 2, 3, 4, 5].map((number) => ( + + + Slide {number} + + + )) + } + + + + +``` diff --git a/tests/data-slot.spec.ts b/tests/data-slot.spec.ts index 842a25833..cddb10979 100644 --- a/tests/data-slot.spec.ts +++ b/tests/data-slot.spec.ts @@ -694,3 +694,46 @@ test("sidebar: mobile nested search, focus restoration and navigation reinitiali ).toBeVisible() } }) + +test("carousel: all visible slides remain interactive in multiple mode", async ({ + page, + isMobile, +}) => { + await open(page, "carousel") + const root = page.locator('[data-slot="carousel"][data-multiple="true"]') + await root.scrollIntoViewIfNeeded() + const items = root.locator('[data-slot="carousel-item"]') + for (let index = 0; index < 3; index++) { + await expect(items.nth(index)).toHaveAttribute("aria-hidden", "false") + await expect(items.nth(index)).not.toHaveAttribute("inert") + } + await expect(items.nth(3)).toHaveAttribute("inert", "") + const second = root.getByRole("link", { name: "Slide 2", exact: true }) + if (isMobile) { + const box = await second.boundingBox() + if (!box) throw new Error("Visible slide has no layout box") + await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2) + } else await second.click() + await expect(page).toHaveURL(/#slide-2$/) + const next = root.getByRole("button", { name: "Next slide" }) + if (isMobile) { + const box = await next.boundingBox() + if (!box) throw new Error("Carousel control has no layout box") + await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2) + } else await next.click() + await expect(root).toHaveAttribute("data-index", "1") + await expect(items.nth(0)).toHaveAttribute("inert", "") + for (let index = 1; index < 4; index++) + await expect(items.nth(index)).toHaveAttribute("aria-hidden", "false") + // The primitive also writes its single-slide state after scrolling settles. + await page.waitForTimeout(300) + const third = root.getByRole("link", { name: "Slide 3", exact: true }) + if (isMobile) { + const box = await third.boundingBox() + if (!box) throw new Error("Visible slide has no layout box") + await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2) + } else await third.click() + await expect(page).toHaveURL(/#slide-3$/) + await page.setViewportSize({ width: 768, height: 1000 }) + await expect(items.nth(2)).not.toHaveAttribute("inert") +}) From 699ddf6fc69683812f02d886e7dc3bdc03decc9e Mon Sep 17 00:00:00 2001 From: Sil Date: Sun, 27 Sep 2026 16:51:15 +0200 Subject: [PATCH 2/3] fix: restore Embla carousel parity --- .changeset/visible-carousel-slides.md | 8 +- package.json | 4 +- pnpm-lock.yaml | 83 ++++++++-- public/r/carousel.json | 17 +- public/r/registry.json | 8 +- public/r/reviews-3.json | 2 +- registry.json | 8 +- src/components/blocks/reviews-3.astro | 5 +- .../ui/carousel/carousel-content.astro | 24 +-- .../ui/carousel/carousel-item.astro | 7 +- .../ui/carousel/carousel-visibility.ts | 53 ------- src/components/ui/carousel/carousel.astro | 144 +++++++++++++---- src/components/ui/carousel/index.ts | 6 +- src/content/pages/components/carousel.mdx | 113 +++++++------- tests/data-slot.spec.ts | 145 +++++++++++------- 15 files changed, 388 insertions(+), 239 deletions(-) delete mode 100644 src/components/ui/carousel/carousel-visibility.ts diff --git a/.changeset/visible-carousel-slides.md b/.changeset/visible-carousel-slides.md index 9c4b1e4ba..62bccb5d2 100644 --- a/.changeset/visible-carousel-slides.md +++ b/.changeset/visible-carousel-slides.md @@ -1,5 +1,9 @@ --- -"fulldev-ui": patch +"fulldev-ui": minor --- -Add opt-in multi-slide carousel visibility so every visible slide remains interactive and offscreen slides stay hidden from assistive technology. +Restore the Embla-powered Carousel and shadcn-style composition, responsive slide widths and `opts` API. Use the vanilla `embla-carousel` 8.6.0 package without React. Other interactive components continue to use Data Slot directly. + +This replaces the single-slide Carousel introduced in 0.13.0. Reinstall the registry component. Replace `defaultIndex`, `drag` and `loop` with `opts={{ startIndex, watchDrag, loop }}`; dragging is enabled by default. Restore `CarouselApi`, `CarouselOptions` and `CarouselPlugin` types and `carousel:init` / `carousel:select` events. Listen to their `detail.api` for programmatic navigation instead of `carousel:set`. `CarouselContent` classes apply to the inner track again; use matching negative track margins and slide padding for spacing. + +Visible slide links stay interactive, and Embla handles focus scrolling, resizing and slide positions. Instances and event handlers are cleaned up before Astro page swaps. No observers override another library's accessibility state. diff --git a/package.json b/package.json index 57aceaf64..f52f997c3 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "@astrojs/sitemap": "^3.7.3", "@data-slot/accordion": "^1.0.2", "@data-slot/alert-dialog": "^1.0.2", - "@data-slot/carousel": "^1.0.2", "@data-slot/collapsible": "^1.0.2", "@data-slot/combobox": "^1.0.2", "@data-slot/command": "^1.0.2", @@ -114,7 +113,8 @@ "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", "tw-animate-css": "^1.4.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "embla-carousel": "8.6.0" }, "devDependencies": { "@argos-ci/playwright": "^7.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f988a8e8..2e614ba2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: '@data-slot/alert-dialog': specifier: ^1.0.2 version: 1.0.2 - '@data-slot/carousel': - specifier: ^1.0.2 - version: 1.0.2 '@data-slot/collapsible': specifier: ^1.0.2 version: 1.0.2 @@ -116,6 +113,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + embla-carousel: + specifier: 8.6.0 + version: 8.6.0 lucide-static: specifier: ^1.22.0 version: 1.34.0 @@ -462,9 +462,6 @@ packages: '@data-slot/alert-dialog@1.0.2': resolution: {integrity: sha512-muBiDNPYF3EXvNTC0nMK3qff95vaPdndGARaHsYFKx0tUI++6wkncIOPD0/PbSFng4wpIssVf4Ila7+VeXCxzg==} - '@data-slot/carousel@1.0.2': - resolution: {integrity: sha512-6ElknTlUM8rKp0sk5TN7gamQgXtmQo/9DIpAChOUXsmwa9Rp27sNO2N9OxMV+FSa3qfL7mImt7idJbYNZHe82g==} - '@data-slot/collapsible@1.0.2': resolution: {integrity: sha512-aqBQpbbZBfA+1dQJ5MMMpd17pTCdiphqG77KbAuyctVCIUcT0QCtJ8XT/aw2mOrlAzFPuuTYuvQNhqauRyM+9Q==} @@ -834,243 +831,287 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm64@1.3.2': resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.3.2': resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.3.2': resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.3.2': resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.3.2': resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.3.2': resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-arm64@1.3.2': resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.3.2': resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm64@0.35.3': resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.35.3': resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.35.3': resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.35.3': resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.35.3': resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.35.3': resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-arm64@0.35.3': resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.35.3': resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -1344,66 +1385,79 @@ packages: resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.3': resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.3': resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.3': resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.3': resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.3': resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.3': resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.3': resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.3': resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.3': resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.3': resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.3': resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.3': resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.3': resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} @@ -1511,24 +1565,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.3': resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.3': resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.3': resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.3': resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} @@ -2141,6 +2199,9 @@ packages: electron-to-chromium@1.5.349: resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==} + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emmet@2.4.11: resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} @@ -2779,24 +2840,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -4863,10 +4928,6 @@ snapshots: dependencies: '@data-slot/core': 1.0.2 - '@data-slot/carousel@1.0.2': - dependencies: - '@data-slot/core': 1.0.2 - '@data-slot/collapsible@1.0.2': dependencies: '@data-slot/core': 1.0.2 @@ -6480,6 +6541,8 @@ snapshots: electron-to-chromium@1.5.349: {} + embla-carousel@8.6.0: {} + emmet@2.4.11: dependencies: '@emmetio/abbreviation': 2.3.3 diff --git a/public/r/carousel.json b/public/r/carousel.json index 0dddb1122..6bb6b84aa 100644 --- a/public/r/carousel.json +++ b/public/r/carousel.json @@ -2,9 +2,9 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "carousel", "title": "Carousel", - "description": "An accessible carousel built on native scrolling with Data Slot.", + "description": "A carousel with motion, swipe and responsive slides built using Embla.", "dependencies": [ - "@data-slot/carousel@^1.0.2", + "embla-carousel@8.6.0", "@lucide/astro" ], "registryDependencies": [ @@ -13,17 +13,17 @@ "files": [ { "path": "src/components/ui/carousel/carousel.astro", - "content": "---\nimport type { HTMLAttributes } from \"astro/types\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype Props = HTMLAttributes<\"div\"> & {\n defaultIndex?: number\n orientation?: \"horizontal\" | \"vertical\"\n drag?: boolean\n loop?: boolean\n multiple?: boolean\n}\n\nconst {\n class: className,\n defaultIndex,\n orientation = \"horizontal\",\n drag,\n loop,\n multiple,\n ...props\n} = Astro.props\n---\n\n\n \n\n\n\n", + "content": "---\nimport type { HTMLAttributes } from \"astro/types\"\nimport type { EmblaOptionsType } from \"embla-carousel\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype Props = HTMLAttributes<\"div\"> & {\n opts?: EmblaOptionsType\n orientation?: \"horizontal\" | \"vertical\"\n}\n\nconst {\n class: className,\n opts,\n orientation = opts?.axis === \"y\" ? \"vertical\" : \"horizontal\",\n ...props\n} = Astro.props\n\nconst resolvedOpts: EmblaOptionsType = {\n ...opts,\n axis: orientation === \"horizontal\" ? \"x\" : \"y\",\n}\n---\n\n\n \n\n\n\n", "type": "registry:ui" }, { "path": "src/components/ui/carousel/carousel-content.astro", - "content": "---\nimport type { HTMLAttributes } from \"astro/types\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype Props = HTMLAttributes<\"div\">\n\nconst { class: className, ...props } = Astro.props\n---\n\n\n \n\n", + "content": "---\nimport type { HTMLAttributes } from \"astro/types\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype Props = HTMLAttributes<\"div\">\n\nconst { class: className, ...props } = Astro.props\n---\n\n
\n \n \n
\n\n", "type": "registry:ui" }, { "path": "src/components/ui/carousel/carousel-item.astro", - "content": "---\nimport type { HTMLAttributes } from \"astro/types\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype Props = HTMLAttributes<\"div\">\n\nconst { class: className, ...props } = Astro.props\n---\n\n\n \n\n", + "content": "---\nimport type { HTMLAttributes } from \"astro/types\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype Props = HTMLAttributes<\"div\">\n\nconst { class: className, ...props } = Astro.props\n---\n\n\n \n\n", "type": "registry:ui" }, { @@ -38,13 +38,8 @@ }, { "path": "src/components/ui/carousel/index.ts", - "content": "export type { CarouselController, CarouselOptions } from \"@data-slot/carousel\"\n\nexport { default as Carousel } from \"./carousel.astro\"\nexport { default as CarouselContent } from \"./carousel-content.astro\"\nexport { default as CarouselItem } from \"./carousel-item.astro\"\nexport { default as CarouselPrevious } from \"./carousel-previous.astro\"\nexport { default as CarouselNext } from \"./carousel-next.astro\"\n", + "content": "export type {\n EmblaCarouselType as CarouselApi,\n EmblaOptionsType as CarouselOptions,\n EmblaPluginType as CarouselPlugin,\n} from \"embla-carousel\"\n\nexport { default as Carousel } from \"./carousel.astro\"\nexport { default as CarouselContent } from \"./carousel-content.astro\"\nexport { default as CarouselItem } from \"./carousel-item.astro\"\nexport { default as CarouselPrevious } from \"./carousel-previous.astro\"\nexport { default as CarouselNext } from \"./carousel-next.astro\"\n", "type": "registry:ui" - }, - { - "path": "src/components/ui/carousel/carousel-visibility.ts", - "content": "/** Keep the visible portion of a multi-slide carousel accessible. */\nexport function observeCarouselVisibility(root: HTMLElement) {\n const content = root.querySelector(\n '[data-slot=\"carousel-content\"]'\n )\n if (!content) return () => {}\n\n let frame: number | undefined\n const update = () => {\n const viewport = content.getBoundingClientRect()\n const vertical = root.dataset.orientation === \"vertical\"\n for (const item of content.children) {\n if (!(item instanceof HTMLElement)) continue\n if (item.dataset.slot !== \"carousel-item\") continue\n resize.observe(item)\n const bounds = item.getBoundingClientRect()\n const visible = vertical\n ? bounds.bottom > viewport.top + 1 && bounds.top < viewport.bottom - 1\n : bounds.right > viewport.left + 1 && bounds.left < viewport.right - 1\n if (item.inert === visible) item.inert = !visible\n const hidden = String(!visible)\n if (item.getAttribute(\"aria-hidden\") !== hidden)\n item.setAttribute(\"aria-hidden\", hidden)\n }\n }\n const schedule = () => {\n frame ??= requestAnimationFrame(() => {\n frame = undefined\n update()\n })\n }\n\n // The single-slide primitive updates these attributes after scroll settles.\n // Observe that update as well as geometry so visible links stay operable.\n const mutation = new MutationObserver(update)\n mutation.observe(content, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: [\"inert\", \"aria-hidden\"],\n })\n const resize = new ResizeObserver(schedule)\n resize.observe(content)\n content.addEventListener(\"scroll\", schedule, { passive: true })\n update()\n\n return () => {\n mutation.disconnect()\n resize.disconnect()\n content.removeEventListener(\"scroll\", schedule)\n if (frame !== undefined) cancelAnimationFrame(frame)\n }\n}\n", - "type": "registry:component" } ], "docs": "https://ui.full.dev/components/carousel/", diff --git a/public/r/registry.json b/public/r/registry.json index d6a99ae48..2f97e82c8 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -1079,13 +1079,13 @@ "name": "carousel", "type": "registry:ui", "title": "Carousel", - "description": "An accessible carousel built on native scrolling with Data Slot.", + "description": "A carousel with motion, swipe and responsive slides built using Embla.", "categories": [ "ui" ], "docs": "https://ui.full.dev/components/carousel/", "dependencies": [ - "@data-slot/carousel@^1.0.2", + "embla-carousel@8.6.0", "@lucide/astro" ], "registryDependencies": [ @@ -1115,10 +1115,6 @@ { "path": "src/components/ui/carousel/index.ts", "type": "registry:ui" - }, - { - "path": "src/components/ui/carousel/carousel-visibility.ts", - "type": "registry:component" } ] }, diff --git a/public/r/reviews-3.json b/public/r/reviews-3.json index cc38797dc..fd4c8ad71 100644 --- a/public/r/reviews-3.json +++ b/public/r/reviews-3.json @@ -15,7 +15,7 @@ "files": [ { "path": "src/components/blocks/reviews-3.astro", - "content": "---\nimport type { ImageMetadata } from \"astro\"\nimport type { HTMLAttributes } from \"astro/types\"\nimport QuoteIcon from \"@lucide/astro/icons/quote\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\"\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n CarouselNext,\n CarouselPrevious,\n} from \"@/components/ui/carousel\"\nimport { Rating } from \"@/components/ui/rating\"\nimport {\n Section,\n SectionContainer,\n SectionDescription,\n SectionTitle,\n} from \"@/components/ui/section\"\n\ntype Props = HTMLAttributes<\"section\"> & {\n title?: string\n description?: string\n reviews: {\n rating: number\n quote: string\n image?: ImageMetadata\n initials: string\n name: string\n role: string\n }[]\n}\n\nconst { class: className, title, description, reviews, ...props } = Astro.props\n---\n\n
\n {\n (title || description) && (\n \n {title && {title}}\n {description && (\n \n {description}\n \n )}\n \n )\n }\n \n \n \n {\n reviews.map((item) => (\n \n
\n \n \n
\n {item.quote}\n
\n
\n \n \n {item.initials}\n \n
\n

\n {item.name}\n

\n

{item.role}

\n
\n
\n
\n
\n ))\n }\n
\n \n \n
\n
\n
\n", + "content": "---\nimport type { ImageMetadata } from \"astro\"\nimport type { HTMLAttributes } from \"astro/types\"\nimport QuoteIcon from \"@lucide/astro/icons/quote\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\"\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n CarouselNext,\n CarouselPrevious,\n} from \"@/components/ui/carousel\"\nimport { Rating } from \"@/components/ui/rating\"\nimport {\n Section,\n SectionContainer,\n SectionDescription,\n SectionTitle,\n} from \"@/components/ui/section\"\n\ntype Props = HTMLAttributes<\"section\"> & {\n title?: string\n description?: string\n reviews: {\n rating: number\n quote: string\n image?: ImageMetadata\n initials: string\n name: string\n role: string\n }[]\n}\n\nconst { class: className, title, description, reviews, ...props } = Astro.props\n---\n\n
\n {\n (title || description) && (\n \n {title && {title}}\n {description && (\n \n {description}\n \n )}\n \n )\n }\n \n \n \n {\n reviews.map((item) => (\n \n
\n \n \n
\n {item.quote}\n
\n
\n \n \n {item.initials}\n \n
\n

\n {item.name}\n

\n

{item.role}

\n
\n
\n
\n
\n ))\n }\n
\n \n \n \n
\n
\n", "type": "registry:block" } ], diff --git a/registry.json b/registry.json index 8fd31aea4..276e60e8a 100644 --- a/registry.json +++ b/registry.json @@ -981,10 +981,10 @@ "name": "carousel", "type": "registry:ui", "title": "Carousel", - "description": "An accessible carousel built on native scrolling with Data Slot.", + "description": "A carousel with motion, swipe and responsive slides built using Embla.", "categories": ["ui"], "docs": "https://ui.full.dev/components/carousel/", - "dependencies": ["@data-slot/carousel@^1.0.2", "@lucide/astro"], + "dependencies": ["embla-carousel@8.6.0", "@lucide/astro"], "registryDependencies": ["@fulldev/button"], "files": [ { @@ -1010,10 +1010,6 @@ { "path": "src/components/ui/carousel/index.ts", "type": "registry:ui" - }, - { - "path": "src/components/ui/carousel/carousel-visibility.ts", - "type": "registry:component" } ] }, diff --git a/src/components/blocks/reviews-3.astro b/src/components/blocks/reviews-3.astro index cdb54608e..65599dc3d 100644 --- a/src/components/blocks/reviews-3.astro +++ b/src/components/blocks/reviews-3.astro @@ -50,7 +50,10 @@ const { class: className, title, description, reviews, ...props } = Astro.props ) } - + { reviews.map((item) => ( diff --git a/src/components/ui/carousel/carousel-content.astro b/src/components/ui/carousel/carousel-content.astro index 4bf30500a..a48c82f17 100644 --- a/src/components/ui/carousel/carousel-content.astro +++ b/src/components/ui/carousel/carousel-content.astro @@ -8,15 +8,17 @@ type Props = HTMLAttributes<"div"> const { class: className, ...props } = Astro.props --- -
- +
+
+ +
diff --git a/src/components/ui/carousel/carousel-item.astro b/src/components/ui/carousel/carousel-item.astro index 2145af160..5fca8ab12 100644 --- a/src/components/ui/carousel/carousel-item.astro +++ b/src/components/ui/carousel/carousel-item.astro @@ -10,9 +10,12 @@ const { class: className, ...props } = Astro.props
( - '[data-slot="carousel-content"]' - ) - if (!content) return () => {} - - let frame: number | undefined - const update = () => { - const viewport = content.getBoundingClientRect() - const vertical = root.dataset.orientation === "vertical" - for (const item of content.children) { - if (!(item instanceof HTMLElement)) continue - if (item.dataset.slot !== "carousel-item") continue - resize.observe(item) - const bounds = item.getBoundingClientRect() - const visible = vertical - ? bounds.bottom > viewport.top + 1 && bounds.top < viewport.bottom - 1 - : bounds.right > viewport.left + 1 && bounds.left < viewport.right - 1 - if (item.inert === visible) item.inert = !visible - const hidden = String(!visible) - if (item.getAttribute("aria-hidden") !== hidden) - item.setAttribute("aria-hidden", hidden) - } - } - const schedule = () => { - frame ??= requestAnimationFrame(() => { - frame = undefined - update() - }) - } - - // The single-slide primitive updates these attributes after scroll settles. - // Observe that update as well as geometry so visible links stay operable. - const mutation = new MutationObserver(update) - mutation.observe(content, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ["inert", "aria-hidden"], - }) - const resize = new ResizeObserver(schedule) - resize.observe(content) - content.addEventListener("scroll", schedule, { passive: true }) - update() - - return () => { - mutation.disconnect() - resize.disconnect() - content.removeEventListener("scroll", schedule) - if (frame !== undefined) cancelAnimationFrame(frame) - } -} diff --git a/src/components/ui/carousel/carousel.astro b/src/components/ui/carousel/carousel.astro index 376f4ea05..81bad3d6c 100644 --- a/src/components/ui/carousel/carousel.astro +++ b/src/components/ui/carousel/carousel.astro @@ -1,62 +1,154 @@ --- import type { HTMLAttributes } from "astro/types" +import type { EmblaOptionsType } from "embla-carousel" import { cn } from "@/lib/utils" type Props = HTMLAttributes<"div"> & { - defaultIndex?: number + opts?: EmblaOptionsType orientation?: "horizontal" | "vertical" - drag?: boolean - loop?: boolean - multiple?: boolean } const { class: className, - defaultIndex, - orientation = "horizontal", - drag, - loop, - multiple, + opts, + orientation = opts?.axis === "y" ? "vertical" : "horizontal", ...props } = Astro.props + +const resolvedOpts: EmblaOptionsType = { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", +} ---
diff --git a/src/components/ui/carousel/index.ts b/src/components/ui/carousel/index.ts index 7e06994a2..0019f3ccf 100644 --- a/src/components/ui/carousel/index.ts +++ b/src/components/ui/carousel/index.ts @@ -1,4 +1,8 @@ -export type { CarouselController, CarouselOptions } from "@data-slot/carousel" +export type { + EmblaCarouselType as CarouselApi, + EmblaOptionsType as CarouselOptions, + EmblaPluginType as CarouselPlugin, +} from "embla-carousel" export { default as Carousel } from "./carousel.astro" export { default as CarouselContent } from "./carousel-content.astro" diff --git a/src/content/pages/components/carousel.mdx b/src/content/pages/components/carousel.mdx index 1acd29835..55d6f3ee9 100644 --- a/src/content/pages/components/carousel.mdx +++ b/src/content/pages/components/carousel.mdx @@ -1,7 +1,7 @@ --- type: doc title: Carousel -description: An accessible carousel built on native scrolling with Data Slot. +description: A carousel with motion, swipe and responsive slides built using Embla. --- ```astro live props={{ name: 'carousel' }} @@ -15,7 +15,7 @@ import { } from "@/components/ui/carousel" --- - +
+ + ``` -## Events +## Events and API -Listen for `carousel:change` on the root to react to a new active slide. +Listen for `carousel:init` to receive the Embla API. `carousel:select` fires after +selection or reinitialization. Both events expose `detail.api`, `canScrollPrev`, +`canScrollNext`, `selectedScrollSnap` and `scrollSnapList`. ```ts -const carousel = document.querySelector('[data-slot="carousel"]') +import type { CarouselApi } from "@/components/ui/carousel" -carousel?.addEventListener("carousel:change", (event) => { - const { index } = (event as CustomEvent<{ index: number }>).detail - console.log(index) +const carousel = document.querySelector('[data-slot="carousel"]') +carousel?.addEventListener("carousel:init", (event) => { + const { api } = (event as CustomEvent<{ api: CarouselApi }>).detail + api.scrollTo(2) }) ``` -Dispatch `carousel:set` on the root to select an index or move by one slide. - -```ts -carousel?.dispatchEvent( - new CustomEvent("carousel:set", { detail: { index: 2 } }) -) - -carousel?.dispatchEvent( - new CustomEvent("carousel:set", { detail: { action: "next" } }) -) -``` +Register the listener before initialization. For code running later, the root's +`__fulldevCarouselApi` property holds the current API. `CarouselOptions` and +`CarouselPlugin` re-export Embla's corresponding types. Plugin instances are +configured through Embla's API, not serialized Astro props. -The root exposes its current index as `data-index`. Each item exposes -`data-state="active"` or `data-state="inactive"`. Data Slot also keeps the -previous and next controls' `disabled` and `aria-disabled` states in sync. - -## API Reference - -See the [GitHub source code](https://github.com/fulldotdev/ui/tree/main/src/components/ui/carousel) -for component props and the -[@data-slot/carousel README](https://github.com/bejamas/data-slot/tree/main/packages/carousel) -for controller methods, events, keyboard behavior, and accessibility details. +Instances are destroyed before Astro page swaps and recreated on page load. +Visible links retain normal focus behavior; focusing an offscreen slide lets +Embla bring it into view. No custom `inert` or `aria-hidden` overrides are added. ## Multiple visible slides -Use `multiple` when CSS sizes show more than one slide. Visible slides remain -interactive and available to assistive technology. Offscreen slides stay inert. -The default single-slide behavior is unchanged. +Use CSS widths just as in shadcn/ui. This example shows three clickable slides +at once, with extra slides reachable through dragging, buttons or keyboard. ```astro live props={{ name: 'carousel-multiple' }} --- @@ -238,11 +226,15 @@ import { } from "@/components/ui/carousel" --- - + { [1, 2, 3, 4, 5].map((number) => ( - + ``` + +## Migration from 0.13 + +Reinstall the registry component so markup and runtime stay together. + +| 0.13 | Embla carousel | +| ---------------------------------- | -------------------------------------------------------------- | +| `defaultIndex={1}` | `opts={{ startIndex: 1 }}` | +| `loop` | `opts={{ loop: true }}` | +| `drag` | Enabled by default; disable with `opts={{ watchDrag: false }}` | +| `CarouselController` | `CarouselApi` | +| `carousel:change` | `carousel:select` | +| `carousel:set` | Call methods on `detail.api` from `carousel:init` | +| Content classes style the viewport | Content classes style the inner track | + +The former one-full-width-slide restriction no longer applies. Use matching +negative margins on content and padding on items for spacing, like shadcn/ui. diff --git a/tests/data-slot.spec.ts b/tests/data-slot.spec.ts index cddb10979..91d586829 100644 --- a/tests/data-slot.spec.ts +++ b/tests/data-slot.spec.ts @@ -515,7 +515,7 @@ test("resizable: pointer, keyboard bounds and collapsible panel", async ({ await expect(outerHandle).toHaveAttribute("aria-valuenow", "35") }) -test("carousel: controls, full-slide state and keyboard navigation", async ({ +test("carousel: controls, drag, loop, vertical and keyboard navigation", async ({ page, }) => { await open(page, "carousel") @@ -525,15 +525,12 @@ test("carousel: controls, full-slide state and keyboard navigation", async ({ const previous = root.getByRole("button", { name: "Previous slide" }) const next = root.getByRole("button", { name: "Next slide" }) - await expect(root).toHaveAttribute("data-index", "0") + await expect(root).toHaveAttribute("data-selected-scroll-snap", "0") await expect(previous).toBeDisabled() - await expect(items.nth(0)).toHaveAttribute("data-state", "active") - await expect(items.nth(1)).toHaveAttribute("aria-hidden", "true") - await expect(items.nth(1)).toHaveAttribute("inert", "") const contentBox = await content.boundingBox() const itemBox = await items.nth(0).boundingBox() if (!contentBox || !itemBox) throw new Error("Carousel has no layout box") - expect(Math.abs(contentBox.width - itemBox.width)).toBeLessThanOrEqual(1) + expect(Math.abs(contentBox.width + 16 - itemBox.width)).toBeLessThanOrEqual(1) await page.mouse.move( contentBox.x + contentBox.width * 0.75, @@ -546,40 +543,37 @@ test("carousel: controls, full-slide state and keyboard navigation", async ({ { steps: 6 } ) await page.mouse.up() - await expect(root).toHaveAttribute("data-index", "1") + await expect(root).toHaveAttribute("data-selected-scroll-snap", "1") await previous.click() - await expect(root).toHaveAttribute("data-index", "0") + await expect(root).toHaveAttribute("data-selected-scroll-snap", "0") await next.click() - await expect(root).toHaveAttribute("data-index", "1") - await expect(items.nth(1)).toHaveAttribute("data-state", "active") + await expect(root).toHaveAttribute("data-selected-scroll-snap", "1") await previous.click() - await expect(root).toHaveAttribute("data-index", "0") + await expect(root).toHaveAttribute("data-selected-scroll-snap", "0") await next.focus() await next.press("ArrowRight") - await expect(root).toHaveAttribute("data-index", "1") + await expect(root).toHaveAttribute("data-selected-scroll-snap", "1") await next.press("End") - await expect(root).toHaveAttribute("data-index", "2") + await expect(root).toHaveAttribute("data-selected-scroll-snap", "2") await expect(next).toBeDisabled() await previous.press("Home") - await expect(root).toHaveAttribute("data-index", "0") + await expect(root).toHaveAttribute("data-selected-scroll-snap", "0") const loopRoot = page .locator(".live-code-layout") .nth(1) .locator('[data-slot="carousel"]') - const loopItems = loopRoot.locator('[data-slot="carousel-item"]') const loopPrevious = loopRoot.getByRole("button", { name: "Previous slide" }) const loopNext = loopRoot.getByRole("button", { name: "Next slide" }) - await expect(loopRoot).toHaveAttribute("data-index", "1") - await expect(loopItems.nth(1)).toHaveAttribute("data-state", "active") + await expect(loopRoot).toHaveAttribute("data-selected-scroll-snap", "1") await loopPrevious.click() - await expect(loopRoot).toHaveAttribute("data-index", "0") + await expect(loopRoot).toHaveAttribute("data-selected-scroll-snap", "0") await loopPrevious.click() - await expect(loopRoot).toHaveAttribute("data-index", "2") + await expect(loopRoot).toHaveAttribute("data-selected-scroll-snap", "2") await loopNext.click() - await expect(loopRoot).toHaveAttribute("data-index", "0") + await expect(loopRoot).toHaveAttribute("data-selected-scroll-snap", "0") const verticalRoot = page .locator(".live-code-layout") @@ -594,7 +588,9 @@ test("carousel: controls, full-slide state and keyboard navigation", async ({ name: "Previous slide", }) await verticalContent.scrollIntoViewIfNeeded() - const verticalContentBox = await verticalContent.boundingBox() + const verticalContentBox = await verticalRoot + .locator('[data-slot="carousel-container"]') + .boundingBox() const verticalItemBox = await verticalItems.nth(0).boundingBox() if (!verticalContentBox || !verticalItemBox) throw new Error("Vertical carousel has no layout box") @@ -602,17 +598,37 @@ test("carousel: controls, full-slide state and keyboard navigation", async ({ Math.abs(verticalContentBox.height - verticalItemBox.height) ).toBeLessThanOrEqual(1) await verticalNext.click() - await expect(verticalRoot).toHaveAttribute("data-index", "1") + await expect(verticalRoot).toHaveAttribute("data-selected-scroll-snap", "1") await verticalPrevious.click() - await expect(verticalRoot).toHaveAttribute("data-index", "0") + await expect(verticalRoot).toHaveAttribute("data-selected-scroll-snap", "0") await expect - .poll(() => verticalContent.evaluate((element) => element.scrollTop)) + .poll(() => + verticalItems + .nth(0) + .evaluate( + (element) => + -element.getBoundingClientRect().top + + element + .closest('[data-slot="carousel-content"]')! + .getBoundingClientRect().top + ) + ) .toBeLessThan(verticalContentBox.height * 0.2) await verticalNext.focus() await verticalNext.press("ArrowDown") - await expect(verticalRoot).toHaveAttribute("data-index", "1") + await expect(verticalRoot).toHaveAttribute("data-selected-scroll-snap", "1") await expect - .poll(() => verticalContent.evaluate((element) => element.scrollTop)) + .poll(() => + verticalItems + .nth(0) + .evaluate( + (element) => + -element.getBoundingClientRect().top + + element + .closest('[data-slot="carousel-content"]')! + .getBoundingClientRect().top + ) + ) .toBeGreaterThan(verticalContentBox.height * 0.8) const verticalDragBox = await verticalContent.boundingBox() if (!verticalDragBox) throw new Error("Vertical carousel moved out of view") @@ -627,7 +643,7 @@ test("carousel: controls, full-slide state and keyboard navigation", async ({ { steps: 6 } ) await page.mouse.up() - await expect(verticalRoot).toHaveAttribute("data-index", "2") + await expect(verticalRoot).toHaveAttribute("data-selected-scroll-snap", "2") }) test("command: new trigger slot, nested command filtering and dismissal", async ({ @@ -695,45 +711,64 @@ test("sidebar: mobile nested search, focus restoration and navigation reinitiali } }) -test("carousel: all visible slides remain interactive in multiple mode", async ({ +test("carousel: visible links, focus, end controls and responsive sizing", async ({ page, isMobile, + browserName, }) => { await open(page, "carousel") - const root = page.locator('[data-slot="carousel"][data-multiple="true"]') + const root = page.locator('[data-slot="carousel"][data-demo="multiple"]') await root.scrollIntoViewIfNeeded() - const items = root.locator('[data-slot="carousel-item"]') - for (let index = 0; index < 3; index++) { - await expect(items.nth(index)).toHaveAttribute("aria-hidden", "false") - await expect(items.nth(index)).not.toHaveAttribute("inert") - } - await expect(items.nth(3)).toHaveAttribute("inert", "") - const second = root.getByRole("link", { name: "Slide 2", exact: true }) + const links = root.getByRole("link") + const second = links.nth(1) if (isMobile) { const box = await second.boundingBox() if (!box) throw new Error("Visible slide has no layout box") await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2) } else await second.click() await expect(page).toHaveURL(/#slide-2$/) + await second.focus() + await expect(second).toBeFocused() + await page.waitForTimeout(200) + await expect(second).toBeFocused() + await second.press(browserName === "webkit" ? "Alt+Tab" : "Tab") + await expect(links.nth(2)).toBeFocused() + await links.nth(2).press(browserName === "webkit" ? "Alt+Tab" : "Tab") + await expect(links.nth(3)).toBeFocused() + await expect(root).toHaveAttribute("data-can-scroll-prev", "true") const next = root.getByRole("button", { name: "Next slide" }) - if (isMobile) { - const box = await next.boundingBox() - if (!box) throw new Error("Carousel control has no layout box") - await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2) - } else await next.click() - await expect(root).toHaveAttribute("data-index", "1") - await expect(items.nth(0)).toHaveAttribute("inert", "") - for (let index = 1; index < 4; index++) - await expect(items.nth(index)).toHaveAttribute("aria-hidden", "false") - // The primitive also writes its single-slide state after scrolling settles. - await page.waitForTimeout(300) - const third = root.getByRole("link", { name: "Slide 3", exact: true }) - if (isMobile) { - const box = await third.boundingBox() - if (!box) throw new Error("Visible slide has no layout box") - await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2) - } else await third.click() - await expect(page).toHaveURL(/#slide-3$/) + await links.nth(3).press("End") + await expect(next).toBeDisabled() + await expect(links.nth(3)).toBeFocused() + await links.nth(3).press("Home") + await expect( + root.getByRole("button", { name: "Previous slide" }) + ).toBeDisabled() await page.setViewportSize({ width: 768, height: 1000 }) - await expect(items.nth(2)).not.toHaveAttribute("inert") + await expect(next).toBeEnabled() + await links.nth(1).focus() + await expect(links.nth(1)).toBeFocused() + await links.nth(1).press("Enter") + await expect(page).toHaveURL(/#slide-2$/) +}) + +test("carousel: Astro cleanup and reinitialization do not duplicate controls", async ({ + page, +}) => { + await open(page, "carousel") + const root = demo(page).locator('[data-slot="carousel"]') + for (let pass = 0; pass < 2; pass++) { + const cleaned = await root.evaluate((element) => { + document.dispatchEvent(new Event("astro:before-swap")) + return !("__fulldevCarouselApi" in element) + }) + expect(cleaned).toBe(true) + await page.evaluate(() => { + document.dispatchEvent(new Event("astro:page-load")) + document.dispatchEvent(new Event("astro:page-load")) + }) + await expect(root).toHaveAttribute("data-selected-scroll-snap", "0") + await root.getByRole("button", { name: "Next slide" }).click() + await expect(root).toHaveAttribute("data-selected-scroll-snap", "1") + } }) From ff11220148c7c7508b6ac6505d4e9de3002b3ef9 Mon Sep 17 00:00:00 2001 From: Sil Date: Sun, 27 Sep 2026 16:55:35 +0200 Subject: [PATCH 3/3] docs: use installable carousel registry name in example --- src/content/pages/components/carousel.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/content/pages/components/carousel.mdx b/src/content/pages/components/carousel.mdx index 55d6f3ee9..b979a2f54 100644 --- a/src/content/pages/components/carousel.mdx +++ b/src/content/pages/components/carousel.mdx @@ -109,7 +109,7 @@ Carousel ## Options -Pass [Embla options](https://www.embla-carousel.com/) through `opts`: +Pass serializable [Embla options](https://www.embla-carousel.com/) through `opts`: `align`, `loop`, `startIndex`, `watchDrag`, `slidesToScroll` and responsive `breakpoints` are supported. `orientation="vertical"` sets the scrolling axis; otherwise `opts.axis` is respected. Dragging and focus scrolling use Embla's @@ -215,7 +215,7 @@ Embla bring it into view. No custom `inert` or `aria-hidden` overrides are added Use CSS widths just as in shadcn/ui. This example shows three clickable slides at once, with extra slides reachable through dragging, buttons or keyboard. -```astro live props={{ name: 'carousel-multiple' }} +```astro live props={{ name: 'carousel' }} --- import { Carousel,