diff --git a/.claude/rules/components.md b/.claude/rules/components.md index 538efe1b4..a2bddfd04 100644 --- a/.claude/rules/components.md +++ b/.claude/rules/components.md @@ -79,9 +79,10 @@ ComponentName/ ### Rules - `defineOptions({ name: '...' })` — **always required** (100%) -- All imports go in ` + + + + + + + Slide {{ i }} + + + + + + + + + + + + + + + diff --git a/apps/docs/src/examples/components/carousel/indicator.vue b/apps/docs/src/examples/components/carousel/indicator.vue new file mode 100644 index 000000000..beb4b162f --- /dev/null +++ b/apps/docs/src/examples/components/carousel/indicator.vue @@ -0,0 +1,38 @@ + + + + + + + + Slide {{ i }} + + + + + + + + + + + + + + + + + diff --git a/apps/docs/src/examples/components/carousel/multi-slide.vue b/apps/docs/src/examples/components/carousel/multi-slide.vue new file mode 100644 index 000000000..aca7aebc0 --- /dev/null +++ b/apps/docs/src/examples/components/carousel/multi-slide.vue @@ -0,0 +1,55 @@ + + + + + + + {{ item.label }} + + + + + + Previous + + + + Next + + + + + + + + + + + Autoplay + + + diff --git a/apps/docs/src/examples/components/carousel/peek.vue b/apps/docs/src/examples/components/carousel/peek.vue new file mode 100644 index 000000000..dca5fea7a --- /dev/null +++ b/apps/docs/src/examples/components/carousel/peek.vue @@ -0,0 +1,26 @@ + + + + + + + {{ slide.label }} + + + + diff --git a/apps/docs/src/pages/components/index.md b/apps/docs/src/pages/components/index.md index 996c30b1c..fe16e97af 100644 --- a/apps/docs/src/pages/components/index.md +++ b/apps/docs/src/pages/components/index.md @@ -76,6 +76,7 @@ Components with meaningful HTML defaults. Render semantic elements by default bu | - | - | | [Avatar](/components/semantic/avatar) | Image/fallback avatar with priority loading | | [Breadcrumbs](/components/semantic/breadcrumbs) | Navigation breadcrumbs with overflow detection and truncation | +| [Carousel](/components/semantic/carousel) | Scroll-snap slide navigation with multi-slide display and drag/swipe | | [Pagination](/components/semantic/pagination) | Page navigation with semantic `` wrapper | | [Progress](/components/semantic/progress/) | Headless progress bar with multi-segment and buffer support | | [Snackbar](/components/semantic/snackbar) | Toast notification with queue, positioning, and auto-dismiss | diff --git a/apps/docs/src/pages/components/semantic/carousel.md b/apps/docs/src/pages/components/semantic/carousel.md new file mode 100644 index 000000000..18399d22c --- /dev/null +++ b/apps/docs/src/pages/components/semantic/carousel.md @@ -0,0 +1,227 @@ +--- +title: Carousel - Scrollable Slide Navigation with Drag Support +meta: +- name: description + content: Headless carousel component built on CSS scroll-snap with multi-slide display, partial-slide peeking, and drag/swipe navigation for Vue 3. +- name: keywords + content: carousel, slider, scroll-snap, drag, swipe, slides, Vue 3, headless, accessible +features: + category: Component + label: 'C: Carousel' + github: /components/Carousel/ + renderless: false + level: 2 +related: + - /components/providers/step + - /composables/selection/create-step + - /components/primitives/atom +--- + +# Carousel + +Headless carousel built on CSS scroll-snap with multi-slide display and partial-slide peeking. + + + +## Usage + +The Carousel provides slide navigation with native drag/swipe via CSS scroll-snap. Slides register with a step context for programmatic navigation. + +::: example +/components/carousel/basic +::: + +## Anatomy + +```vue Anatomy playground no-filename + + + + + + + + + + + + + + + +``` + +## Examples + +### Indicator + +Dot indicators show which slide is active and allow direct navigation. The `Carousel.Indicator` exposes an `items` array via slot props — render each dot with `v-bind="item.attrs"` for built-in keyboard navigation and ARIA. + +::: example +/components/carousel/indicator + +### Dot Navigation + +Indicators between Previous/Next buttons with roving tabindex and `aria-controls` linking each dot to its slide. + +::: + +### Multi-Slide Display + +Show multiple slides at once with the `per-view` prop. This is useful for card grids, skill lists, or product carousels where users can browse items in groups. + +::: example +/components/carousel/multi-slide + +### Three Slides Per View + +A circular carousel showing 3 slides at once with a 12px gap between them. + +::: + +### Peek + +Add padding to the viewport to reveal a portion of adjacent slides, signaling to the user that more content is available and encouraging drag/swipe interaction. + +::: example +/components/carousel/peek + +### Partial Slide Visibility + +A carousel with 48px peek on each side showing portions of adjacent slides. + +::: + +## Recipes + +### Circular Navigation + +Enable wrapping from last slide to first with the `circular` prop. + +```vue + + + + + Slide {{ i }} + + + + +``` + +### Vertical Orientation + +Set `orientation="vertical"` for a vertically scrolling carousel. + +```vue + + + + + Slide {{ i }} + + + + +``` + +### Disabled State + +Disable all navigation via the `disabled` prop on the root. + +```vue + + + + + Slide {{ i }} + + + + Previous + Next + + +``` + +### Instant Scroll + +Set `behavior="instant"` to disable smooth scrolling on programmatic navigation. + +```vue + + + + + Slide {{ i }} + + + + +``` + +### Styling with Data Attributes + +All component state is exposed via `data-*` attributes for CSS-only styling: + +```css +/* Style the selected slide */ +[data-selected] { opacity: 1; } + +/* Dim inactive slides */ +[aria-hidden="true"] { opacity: 0.4; } + +/* Hide nav buttons at boundaries */ +[data-edge] { visibility: hidden; } + +/* Drag interaction */ +[data-dragging] { cursor: grabbing; user-select: none; } +``` + +## Accessibility + +The Carousel implements the [WAI-ARIA Carousel Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/). + +| Element | Role / Attribute | +| - | - | +| Root | `role="region"`, `aria-roledescription="carousel"`, `aria-label`, `aria-disabled` | +| Viewport | `aria-live="polite"` | +| Slide | `role="group"`, `aria-roledescription="slide"`, `aria-label="N of M"` | +| Previous | `aria-label` via locale, `aria-controls` links to viewport | +| Next | `aria-label` via locale, `aria-controls` links to viewport | +| Indicator | `role="tablist"` container with `aria-orientation`, `role="tab"` per dot, `aria-selected` | +| Progress | `role="progressbar"`, `aria-valuenow`, `aria-valuemin`, `aria-valuemax` | +| LiveRegion | `role="status"`, `aria-live="polite"`, `aria-atomic` | + +Slides outside the visible window are marked with `aria-hidden="true"` so screen readers only announce visible content. The LiveRegion provides a dedicated announcement channel for slide changes, using a 100ms delay for reliable screen reader detection. + +::: faq + +??? How does multi-slide display work? + +The `per-view` prop controls how many slides are visible at once. The Viewport measures its own layout (padding, gaps) from the DOM and computes each slide's width automatically. It uses CSS scroll-snap so the browser handles all scroll physics and snap behavior natively. + +??? Can I use fade transitions instead of sliding? + +The Carousel is built on CSS scroll-snap for native drag/swipe support. For fade transitions, use the `Step` provider component with `Presence` for mount/unmount animations — it gives you the same navigation model (next/prev/circular) without scroll-based layout. + +??? How do I build an autoplay carousel? + +Use the `autoplay` prop with an interval in milliseconds. The root slot exposes `isAutoplay`, `isPaused`, `remaining`, `play`, `stop`, `pause`, and `resume` for controlling playback. Autoplay pauses automatically during touch and mouse drag interactions. Use `Carousel.Progress` to visualize the timer. + +```vue + + + + + + + +``` + +::: + + diff --git a/packages/0/README.md b/packages/0/README.md index ab98637bf..67fcd3e30 100644 --- a/packages/0/README.md +++ b/packages/0/README.md @@ -137,6 +137,7 @@ import { ... } from '@vuetify/v0/date' // Date adapter and utilities |-----------|-------------| | [Avatar](https://0.vuetifyjs.com/components/semantic/avatar) | Image/fallback avatar with priority loading | | [Breadcrumbs](https://0.vuetifyjs.com/components/semantic/breadcrumbs) | Navigation breadcrumbs with overflow detection and truncation | +| [Carousel](https://0.vuetifyjs.com/components/semantic/carousel) | Scroll-snap slide navigation with multi-slide display and drag/swipe | | [Pagination](https://0.vuetifyjs.com/components/semantic/pagination) | Page navigation with semantic `` wrapper | | [Snackbar](https://0.vuetifyjs.com/components/semantic/snackbar) | Toast notification with queue, positioning, and auto-dismiss | | [Splitter](https://0.vuetifyjs.com/components/semantic/splitter) | Resizable panel layout with drag handles | diff --git a/packages/0/src/components/Carousel/CarouselIndicator.vue b/packages/0/src/components/Carousel/CarouselIndicator.vue new file mode 100644 index 000000000..443f54dfc --- /dev/null +++ b/packages/0/src/components/Carousel/CarouselIndicator.vue @@ -0,0 +1,208 @@ +/** + * @module CarouselIndicator + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @remarks + * Dot indicator navigation for carousel slides. Renders one indicator + * per registered slide with role="tablist" container semantics. Click + * navigates to the slide, keyboard arrow keys navigate between dots. + * Uses roving tabindex for keyboard navigation. + */ + + + + + + + + + + diff --git a/packages/0/src/components/Carousel/CarouselItem.vue b/packages/0/src/components/Carousel/CarouselItem.vue new file mode 100644 index 000000000..7d514bed3 --- /dev/null +++ b/packages/0/src/components/Carousel/CarouselItem.vue @@ -0,0 +1,150 @@ +/** + * @module CarouselItem + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @remarks + * Individual slide that registers with the parent CarouselRoot. + * Provides selection state, visibility, and ARIA attributes via + * scoped slot. Automatically sizes based on perView, gap, and peek. + */ + + + + + + + + + + diff --git a/packages/0/src/components/Carousel/CarouselLiveRegion.vue b/packages/0/src/components/Carousel/CarouselLiveRegion.vue new file mode 100644 index 000000000..189fafeb9 --- /dev/null +++ b/packages/0/src/components/Carousel/CarouselLiveRegion.vue @@ -0,0 +1,130 @@ +/** + * @module CarouselLiveRegion + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @remarks + * Visually hidden live region that announces slide changes to screen readers. + * Uses aria-live="polite" to announce without interrupting the user. + * + * The live region must exist in the DOM before content changes for screen readers + * to detect updates. A small delay (100ms) is used after slide changes to ensure + * reliable announcement across different assistive technologies. + * + * This component should be visually hidden but remain in the DOM. Apply + * visually-hidden CSS (sr-only) to hide it from sighted users while keeping + * it accessible to screen readers. + * + * @see https://tetralogical.com/blog/2024/05/01/why-are-my-live-regions-not-working/ + */ + + + + + + + + + {{ text }} + + + diff --git a/packages/0/src/components/Carousel/CarouselNext.vue b/packages/0/src/components/Carousel/CarouselNext.vue new file mode 100644 index 000000000..2c4d570e2 --- /dev/null +++ b/packages/0/src/components/Carousel/CarouselNext.vue @@ -0,0 +1,116 @@ +/** + * @module CarouselNext + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @remarks + * Navigation button that moves to the next slide. Automatically + * disables at the last visible boundary in non-circular mode. + * Exposes data attributes for styling disabled and boundary states. + */ + + + + + + + + + + diff --git a/packages/0/src/components/Carousel/CarouselPrevious.vue b/packages/0/src/components/Carousel/CarouselPrevious.vue new file mode 100644 index 000000000..f2c8b7fdf --- /dev/null +++ b/packages/0/src/components/Carousel/CarouselPrevious.vue @@ -0,0 +1,112 @@ +/** + * @module CarouselPrevious + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @remarks + * Navigation button that moves to the previous slide. Automatically + * disables at the first slide in non-circular mode. Exposes data + * attributes for styling disabled and boundary states. + */ + + + + + + + + + + diff --git a/packages/0/src/components/Carousel/CarouselProgress.vue b/packages/0/src/components/Carousel/CarouselProgress.vue new file mode 100644 index 000000000..028439736 --- /dev/null +++ b/packages/0/src/components/Carousel/CarouselProgress.vue @@ -0,0 +1,122 @@ +/** + * @module CarouselProgress + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @remarks + * Visual progress indicator for carousel autoplay timer. Derives a 0-1 + * progress value from the remaining time and total autoplay duration. + * Exposes width styling for CSS-driven animation. Renders role="progressbar" + * with proper ARIA value attributes. + */ + + + + + + + + + + diff --git a/packages/0/src/components/Carousel/CarouselRoot.vue b/packages/0/src/components/Carousel/CarouselRoot.vue new file mode 100644 index 000000000..1578f0fa6 --- /dev/null +++ b/packages/0/src/components/Carousel/CarouselRoot.vue @@ -0,0 +1,270 @@ +/** + * @module CarouselRoot + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @remarks + * Root component for carousel navigation. Creates and provides step context + * to child CarouselViewport, CarouselItem, CarouselPrevious, and CarouselNext + * components. Built on CSS scroll-snap with multi-slide display and peek support. + */ + + + + + + + + + + diff --git a/packages/0/src/components/Carousel/CarouselViewport.vue b/packages/0/src/components/Carousel/CarouselViewport.vue new file mode 100644 index 000000000..cbbc3caf7 --- /dev/null +++ b/packages/0/src/components/Carousel/CarouselViewport.vue @@ -0,0 +1,244 @@ +/** + * @module CarouselViewport + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @remarks + * Scroll-snap container for carousel slides. Handles native scroll-based + * drag/swipe and synchronizes scroll position with the step selection state. + * Provides structural inline styles for scroll-snap behavior and exposes + * data attributes for styling. + */ + + + + + + + + + + diff --git a/packages/0/src/components/Carousel/index.test.ts b/packages/0/src/components/Carousel/index.test.ts new file mode 100644 index 000000000..0265d3ed5 --- /dev/null +++ b/packages/0/src/components/Carousel/index.test.ts @@ -0,0 +1,1022 @@ +import { describe, expect, it } from 'vitest' +import { renderToString } from 'vue/server-renderer' + +// Utilities +import { mount } from '@vue/test-utils' +import { createSSRApp, defineComponent, h, nextTick, ref } from 'vue' + +import { Carousel } from './index' + +describe('carousel', () => { + describe('root', () => { + describe('rendering', () => { + it('should be renderless by default', () => { + const wrapper = mount(Carousel.Root, { + slots: { + default: () => h('div', { class: 'wrapper' }, 'Content'), + }, + }) + + expect(wrapper.find('.wrapper').exists()).toBe(true) + }) + + it('should expose slot props', () => { + let slotProps: any + + mount(Carousel.Root, { + slots: { + default: (props: any) => { + slotProps = props + return h('div', 'Content') + }, + }, + }) + + expect(slotProps).toBeDefined() + expect(typeof slotProps.isDisabled).toBe('boolean') + expect(typeof slotProps.first).toBe('function') + expect(typeof slotProps.last).toBe('function') + expect(typeof slotProps.next).toBe('function') + expect(typeof slotProps.prev).toBe('function') + expect(typeof slotProps.step).toBe('function') + expect(typeof slotProps.select).toBe('function') + expect(slotProps.attrs.role).toBe('region') + expect(slotProps.attrs['aria-roledescription']).toBe('carousel') + }) + + it('should expose orientation and perView in slot props', () => { + let slotProps: any + + mount(Carousel.Root, { + props: { + orientation: 'vertical', + perView: 3, + }, + slots: { + default: (props: any) => { + slotProps = props + return h('div', 'Content') + }, + }, + }) + + expect(slotProps.orientation).toBe('vertical') + expect(slotProps.perView).toBe(3) + }) + }) + + describe('navigation', () => { + it('should navigate to next slide with next()', async () => { + const selected = ref() + + let rootProps: any + let slide1Props: any + let slide2Props: any + + mount(Carousel.Root, { + props: { + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: (props: any) => { + rootProps = props + return [ + h(Carousel.Item as any, { value: 'slide-1' }, { + default: (p: any) => { + slide1Props = p + return h('div', 'Slide 1') + }, + }), + h(Carousel.Item as any, { value: 'slide-2' }, { + default: (p: any) => { + slide2Props = p + return h('div', 'Slide 2') + }, + }), + ] + }, + }, + }) + + await nextTick() + + slide1Props.select() + await nextTick() + expect(slide1Props.isSelected).toBe(true) + + rootProps.next() + await nextTick() + + expect(selected.value).toBe('slide-2') + expect(slide1Props.isSelected).toBe(false) + expect(slide2Props.isSelected).toBe(true) + }) + + it('should navigate to previous slide with prev()', async () => { + const selected = ref() + + let rootProps: any + let slide1Props: any + let slide2Props: any + + mount(Carousel.Root, { + props: { + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: (props: any) => { + rootProps = props + return [ + h(Carousel.Item as any, { value: 'slide-1' }, { + default: (p: any) => { + slide1Props = p + return h('div', 'Slide 1') + }, + }), + h(Carousel.Item as any, { value: 'slide-2' }, { + default: (p: any) => { + slide2Props = p + return h('div', 'Slide 2') + }, + }), + ] + }, + }, + }) + + await nextTick() + + slide2Props.select() + await nextTick() + expect(slide2Props.isSelected).toBe(true) + + rootProps.prev() + await nextTick() + + expect(selected.value).toBe('slide-1') + expect(slide1Props.isSelected).toBe(true) + expect(slide2Props.isSelected).toBe(false) + }) + + it('should not wrap in bounded mode', async () => { + const selected = ref() + + let rootProps: any + let slide2Props: any + + mount(Carousel.Root, { + props: { + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: (props: any) => { + rootProps = props + return [ + h(Carousel.Item as any, { value: 'slide-1' }, { + default: () => h('div', 'Slide 1'), + }), + h(Carousel.Item as any, { value: 'slide-2' }, { + default: (p: any) => { + slide2Props = p + return h('div', 'Slide 2') + }, + }), + ] + }, + }, + }) + + await nextTick() + + // Select last slide + slide2Props.select() + await nextTick() + + // Try to navigate past end + rootProps.next() + await nextTick() + + // Should stay on last slide + expect(slide2Props.isSelected).toBe(true) + }) + + it('should wrap in circular mode', async () => { + const selected = ref() + + let rootProps: any + let slide1Props: any + let slide2Props: any + + mount(Carousel.Root, { + props: { + 'circular': true, + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: (props: any) => { + rootProps = props + return [ + h(Carousel.Item as any, { value: 'slide-1' }, { + default: (p: any) => { + slide1Props = p + return h('div', 'Slide 1') + }, + }), + h(Carousel.Item as any, { value: 'slide-2' }, { + default: (p: any) => { + slide2Props = p + return h('div', 'Slide 2') + }, + }), + ] + }, + }, + }) + + await nextTick() + + slide2Props.select() + await nextTick() + + // Navigate past end - should wrap to first + rootProps.next() + await nextTick() + + expect(selected.value).toBe('slide-1') + expect(slide1Props.isSelected).toBe(true) + }) + + it('should navigate to first and last slides', async () => { + const selected = ref() + + let rootProps: any + let slide1Props: any + let slide3Props: any + + mount(Carousel.Root, { + props: { + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: (props: any) => { + rootProps = props + return [ + h(Carousel.Item as any, { value: 'slide-1' }, { + default: (p: any) => { + slide1Props = p + return h('div', 'Slide 1') + }, + }), + h(Carousel.Item as any, { value: 'slide-2' }, { + default: () => h('div', 'Slide 2'), + }), + h(Carousel.Item as any, { value: 'slide-3' }, { + default: (p: any) => { + slide3Props = p + return h('div', 'Slide 3') + }, + }), + ] + }, + }, + }) + + await nextTick() + + slide1Props.select() + await nextTick() + + rootProps.last() + await nextTick() + expect(selected.value).toBe('slide-3') + expect(slide3Props.isSelected).toBe(true) + + rootProps.first() + await nextTick() + expect(selected.value).toBe('slide-1') + expect(slide1Props.isSelected).toBe(true) + }) + }) + + describe('disabled prop', () => { + it('should disable all slides when root is disabled', async () => { + let slideProps: any + + mount(Carousel.Root, { + props: { + disabled: true, + }, + slots: { + default: () => + h(Carousel.Item as any, { value: 'slide-1' }, { + default: (props: any) => { + slideProps = props + return h('div', 'Slide 1') + }, + }), + }, + }) + + await nextTick() + + expect(slideProps.isDisabled).toBe(true) + }) + }) + + describe('mandatory behavior', () => { + it('should auto-select first slide when mandatory=force', async () => { + let slideProps: any + + mount(Carousel.Root, { + props: { + mandatory: 'force', + }, + slots: { + default: () => + h(Carousel.Item as any, { value: 'slide-1' }, { + default: (props: any) => { + slideProps = props + return h('div', 'Slide 1') + }, + }), + }, + }) + + await nextTick() + + expect(slideProps.isSelected).toBe(true) + }) + }) + }) + + describe('item', () => { + describe('slot props', () => { + it('should expose correct slot props', async () => { + let slideProps: any + + mount(Carousel.Root, { + slots: { + default: () => + h(Carousel.Item as any, { id: 'my-slide', value: 'slide-1' }, { + default: (props: any) => { + slideProps = props + return h('div', 'Slide') + }, + }), + }, + }) + + await nextTick() + + expect(slideProps.id).toBe('my-slide') + expect(typeof slideProps.isSelected).toBe('boolean') + expect(typeof slideProps.isActive).toBe('boolean') + expect(typeof slideProps.isDisabled).toBe('boolean') + expect(typeof slideProps.index).toBe('number') + }) + + it('should expose correct ARIA attrs', async () => { + let slideProps: any + + mount(Carousel.Root, { + slots: { + default: () => + h(Carousel.Item as any, { value: 'slide-1' }, { + default: (props: any) => { + slideProps = props + return h('div', 'Slide') + }, + }), + }, + }) + + await nextTick() + + expect(slideProps.attrs.role).toBe('group') + expect(slideProps.attrs['aria-roledescription']).toBe('slide') + expect(slideProps.attrs['aria-label']).toBeDefined() + }) + + it('should update aria-label with multiple slides', async () => { + let slide1Props: any + let slide2Props: any + let slide3Props: any + + mount(Carousel.Root, { + slots: { + default: () => [ + h(Carousel.Item as any, { value: 'a' }, { + default: (p: any) => { + slide1Props = p + return h('div', 'A') + }, + }), + h(Carousel.Item as any, { value: 'b' }, { + default: (p: any) => { + slide2Props = p + return h('div', 'B') + }, + }), + h(Carousel.Item as any, { value: 'c' }, { + default: (p: any) => { + slide3Props = p + return h('div', 'C') + }, + }), + ], + }, + }) + + await nextTick() + + expect(slide1Props.attrs['aria-label']).toBeDefined() + expect(slide2Props.attrs['aria-label']).toBeDefined() + expect(slide3Props.attrs['aria-label']).toBeDefined() + }) + }) + + describe('data attributes', () => { + it('should set data-selected when selected', async () => { + let slideProps: any + + mount(Carousel.Root, { + props: { + modelValue: 'slide-1', + }, + slots: { + default: () => + h(Carousel.Item as any, { value: 'slide-1' }, { + default: (props: any) => { + slideProps = props + return h('div', 'Slide') + }, + }), + }, + }) + + await nextTick() + + expect(slideProps.attrs['data-selected']).toBe(true) + }) + + it('should set data-active for slides in visible window', async () => { + let slide1Props: any + let slide2Props: any + let slide3Props: any + + mount(Carousel.Root, { + props: { + modelValue: 'a', + perView: 2, + }, + slots: { + default: () => [ + h(Carousel.Item as any, { value: 'a' }, { + default: (p: any) => { + slide1Props = p + return h('div', 'A') + }, + }), + h(Carousel.Item as any, { value: 'b' }, { + default: (p: any) => { + slide2Props = p + return h('div', 'B') + }, + }), + h(Carousel.Item as any, { value: 'c' }, { + default: (p: any) => { + slide3Props = p + return h('div', 'C') + }, + }), + ], + }, + }) + + await nextTick() + + // With perView=2, slides 0 and 1 should be active + expect(slide1Props.attrs['data-active']).toBe(true) + expect(slide2Props.attrs['data-active']).toBe(true) + expect(slide3Props.attrs['data-active']).toBeUndefined() + }) + + it('should set aria-hidden for slides outside visible window', async () => { + let slide1Props: any + let slide2Props: any + + mount(Carousel.Root, { + props: { + modelValue: 'a', + perView: 1, + }, + slots: { + default: () => [ + h(Carousel.Item as any, { value: 'a' }, { + default: (p: any) => { + slide1Props = p + return h('div', 'A') + }, + }), + h(Carousel.Item as any, { value: 'b' }, { + default: (p: any) => { + slide2Props = p + return h('div', 'B') + }, + }), + ], + }, + }) + + await nextTick() + + expect(slide1Props.attrs['aria-hidden']).toBeUndefined() + expect(slide2Props.attrs['aria-hidden']).toBe(true) + }) + + it('should set data-disabled when disabled', async () => { + let slideProps: any + + mount(Carousel.Root, { + slots: { + default: () => + h(Carousel.Item as any, { value: 'slide-1', disabled: true }, { + default: (props: any) => { + slideProps = props + return h('div', 'Slide') + }, + }), + }, + }) + + await nextTick() + + expect(slideProps.attrs['data-disabled']).toBe(true) + }) + + it('should set data-index on each slide', async () => { + let slide1Props: any + let slide2Props: any + + mount(Carousel.Root, { + slots: { + default: () => [ + h(Carousel.Item as any, { value: 'a' }, { + default: (p: any) => { + slide1Props = p + return h('div', 'A') + }, + }), + h(Carousel.Item as any, { value: 'b' }, { + default: (p: any) => { + slide2Props = p + return h('div', 'B') + }, + }), + ], + }, + }) + + await nextTick() + + expect(slide1Props.attrs['data-index']).toBe(0) + expect(slide2Props.attrs['data-index']).toBe(1) + }) + }) + + describe('inline styles', () => { + it('should set scroll-snap-align style', async () => { + let slideProps: any + + mount(Carousel.Root, { + slots: { + default: () => + h(Carousel.Item as any, { value: 'a' }, { + default: (p: any) => { + slideProps = p + return h('div', 'A') + }, + }), + }, + }) + + await nextTick() + + expect(slideProps.attrs.style['scroll-snap-align']).toBe('start') + }) + + it('should not include flex basis (consumer controls sizing)', async () => { + let slideProps: any + + mount(Carousel.Root, { + slots: { + default: () => + h(Carousel.Item as any, { value: 'a' }, { + default: (p: any) => { + slideProps = p + return h('div', 'A') + }, + }), + }, + }) + + await nextTick() + + expect(slideProps.attrs.style.flex).toBeUndefined() + }) + }) + }) + + describe('previous button', () => { + it('should expose correct slot props', async () => { + let prevProps: any + + mount(Carousel.Root, { + slots: { + default: () => [ + h(Carousel.Item as any, { value: 'a' }, { default: () => h('div', 'A') }), + h(Carousel.Previous as any, {}, { + default: (p: any) => { + prevProps = p + return h('button', 'Prev') + }, + }), + ], + }, + }) + + await nextTick() + + expect(typeof prevProps.isDisabled).toBe('boolean') + expect(typeof prevProps.isAtEdge).toBe('boolean') + expect(prevProps.attrs['aria-label']).toBeDefined() + expect(prevProps.attrs.type).toBe('button') + }) + + it('should be disabled at first slide in non-circular mode', async () => { + let prevProps: any + + mount(Carousel.Root, { + props: { + modelValue: 'a', + }, + slots: { + default: () => [ + h(Carousel.Item as any, { value: 'a' }, { default: () => h('div', 'A') }), + h(Carousel.Item as any, { value: 'b' }, { default: () => h('div', 'B') }), + h(Carousel.Previous as any, {}, { + default: (p: any) => { + prevProps = p + return h('button', 'Prev') + }, + }), + ], + }, + }) + + await nextTick() + + expect(prevProps.isAtEdge).toBe(true) + expect(prevProps.isDisabled).toBe(true) + expect(prevProps.attrs['data-edge']).toBe(true) + expect(prevProps.attrs['data-disabled']).toBe(true) + }) + + it('should not be disabled at first slide in circular mode', async () => { + let prevProps: any + + mount(Carousel.Root, { + props: { + modelValue: 'a', + circular: true, + }, + slots: { + default: () => [ + h(Carousel.Item as any, { value: 'a' }, { default: () => h('div', 'A') }), + h(Carousel.Item as any, { value: 'b' }, { default: () => h('div', 'B') }), + h(Carousel.Previous as any, {}, { + default: (p: any) => { + prevProps = p + return h('button', 'Prev') + }, + }), + ], + }, + }) + + await nextTick() + + expect(prevProps.isAtEdge).toBe(false) + expect(prevProps.isDisabled).toBe(false) + }) + }) + + describe('next button', () => { + it('should expose correct slot props', async () => { + let nextBtnProps: any + + mount(Carousel.Root, { + slots: { + default: () => [ + h(Carousel.Item as any, { value: 'a' }, { default: () => h('div', 'A') }), + h(Carousel.Next as any, {}, { + default: (p: any) => { + nextBtnProps = p + return h('button', 'Next') + }, + }), + ], + }, + }) + + await nextTick() + + expect(typeof nextBtnProps.isDisabled).toBe('boolean') + expect(typeof nextBtnProps.isAtEdge).toBe('boolean') + expect(nextBtnProps.attrs['aria-label']).toBeDefined() + expect(nextBtnProps.attrs.type).toBe('button') + }) + + it('should be disabled at last slide in non-circular mode', async () => { + let nextBtnProps: any + + mount(Carousel.Root, { + props: { + modelValue: 'b', + }, + slots: { + default: () => [ + h(Carousel.Item as any, { value: 'a' }, { default: () => h('div', 'A') }), + h(Carousel.Item as any, { value: 'b' }, { default: () => h('div', 'B') }), + h(Carousel.Next as any, {}, { + default: (p: any) => { + nextBtnProps = p + return h('button', 'Next') + }, + }), + ], + }, + }) + + await nextTick() + + expect(nextBtnProps.isAtEdge).toBe(true) + expect(nextBtnProps.isDisabled).toBe(true) + }) + + it('should account for perView when computing edge', async () => { + let nextBtnProps: any + + mount(Carousel.Root, { + props: { + modelValue: 'a', + perView: 2, + }, + slots: { + default: () => [ + h(Carousel.Item as any, { value: 'a' }, { default: () => h('div', 'A') }), + h(Carousel.Item as any, { value: 'b' }, { default: () => h('div', 'B') }), + h(Carousel.Next as any, {}, { + default: (p: any) => { + nextBtnProps = p + return h('button', 'Next') + }, + }), + ], + }, + }) + + await nextTick() + + // perView=2, 2 slides: selectedIndex(0) >= size(2) - perView(2) = 0 + expect(nextBtnProps.isAtEdge).toBe(true) + }) + + it('should not be disabled at last slide in circular mode', async () => { + let nextBtnProps: any + + mount(Carousel.Root, { + props: { + modelValue: 'b', + circular: true, + }, + slots: { + default: () => [ + h(Carousel.Item as any, { value: 'a' }, { default: () => h('div', 'A') }), + h(Carousel.Item as any, { value: 'b' }, { default: () => h('div', 'B') }), + h(Carousel.Next as any, {}, { + default: (p: any) => { + nextBtnProps = p + return h('button', 'Next') + }, + }), + ], + }, + }) + + await nextTick() + + expect(nextBtnProps.isAtEdge).toBe(false) + expect(nextBtnProps.isDisabled).toBe(false) + }) + }) + + describe('integration', () => { + it('should skip disabled slides during navigation', async () => { + const selected = ref() + + let rootProps: any + let slide1Props: any + let slide3Props: any + + mount(Carousel.Root, { + props: { + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: (props: any) => { + rootProps = props + return [ + h(Carousel.Item as any, { value: 'slide-1' }, { + default: (p: any) => { + slide1Props = p + return h('div', 'Slide 1') + }, + }), + h(Carousel.Item as any, { value: 'slide-2', disabled: true }, { + default: () => h('div', 'Slide 2'), + }), + h(Carousel.Item as any, { value: 'slide-3' }, { + default: (p: any) => { + slide3Props = p + return h('div', 'Slide 3') + }, + }), + ] + }, + }, + }) + + await nextTick() + + slide1Props.select() + await nextTick() + + rootProps.next() + await nextTick() + + expect(selected.value).toBe('slide-3') + expect(slide3Props.isSelected).toBe(true) + }) + + it('should use custom namespace for isolation', async () => { + let carousel1Slide1Props: any + let carousel1Slide2Props: any + let carousel2Slide1Props: any + let carousel2Slide2Props: any + + mount(defineComponent({ + render: () => [ + h(Carousel.Root as any, { namespace: 'carousel-1', mandatory: false }, () => [ + h(Carousel.Item as any, { value: 'slide-a', namespace: 'carousel-1' }, { + default: (props: any) => { + carousel1Slide1Props = props + return h('div', 'Carousel 1 Slide A') + }, + }), + h(Carousel.Item as any, { value: 'slide-b', namespace: 'carousel-1' }, { + default: (props: any) => { + carousel1Slide2Props = props + return h('div', 'Carousel 1 Slide B') + }, + }), + ]), + h(Carousel.Root as any, { namespace: 'carousel-2' }, () => [ + h(Carousel.Item as any, { value: 'slide-a', namespace: 'carousel-2' }, { + default: (props: any) => { + carousel2Slide1Props = props + return h('div', 'Carousel 2 Slide A') + }, + }), + h(Carousel.Item as any, { value: 'slide-b', namespace: 'carousel-2' }, { + default: (props: any) => { + carousel2Slide2Props = props + return h('div', 'Carousel 2 Slide B') + }, + }), + ]), + ], + })) + + await nextTick() + + // Select in carousel 1 only + carousel1Slide1Props.select() + await nextTick() + + // Carousel 1 should have slide-a selected (via select()) + // Carousel 2 should have slide-a selected (via mandatory: force) + // but they must be independent — selecting in one doesn't affect the other + expect(carousel1Slide1Props.isSelected).toBe(true) + expect(carousel1Slide2Props.isSelected).toBe(false) + expect(carousel2Slide1Props.isSelected).toBe(true) + expect(carousel2Slide2Props.isSelected).toBe(false) + }) + + it('should support v-model binding', async () => { + const selected = ref('slide-2') + + let slide1Props: any + let slide2Props: any + + mount(Carousel.Root, { + props: { + 'modelValue': selected.value, + 'onUpdate:modelValue': (value: unknown) => { + selected.value = value as string + }, + }, + slots: { + default: () => [ + h(Carousel.Item as any, { value: 'slide-1' }, { + default: (p: any) => { + slide1Props = p + return h('div', 'Slide 1') + }, + }), + h(Carousel.Item as any, { value: 'slide-2' }, { + default: (p: any) => { + slide2Props = p + return h('div', 'Slide 2') + }, + }), + ], + }, + }) + + await nextTick() + + expect(slide2Props.isSelected).toBe(true) + expect(slide1Props.isSelected).toBe(false) + }) + }) + + describe('sSR / Hydration', () => { + it('should render to string on server without errors', async () => { + const app = createSSRApp(defineComponent({ + render: () => + h(Carousel.Root as any, {}, { + default: () => [ + h(Carousel.Item as any, { value: 'slide-1' }, { + default: (props: any) => h('div', { ...props.attrs }, 'Slide 1'), + }), + h(Carousel.Item as any, { value: 'slide-2' }, { + default: (props: any) => h('div', { ...props.attrs }, 'Slide 2'), + }), + ], + }), + })) + + const html = await renderToString(app) + + expect(html).toBeTruthy() + expect(html).toContain('Slide 1') + expect(html).toContain('Slide 2') + }) + + it('should render ARIA attributes on server', async () => { + const app = createSSRApp(defineComponent({ + render: () => + h(Carousel.Root as any, { modelValue: 'slide-1' }, { + default: () => + h(Carousel.Item as any, { value: 'slide-1' }, { + default: (props: any) => h('div', { ...props.attrs }, 'Slide 1'), + }), + }), + })) + + const html = await renderToString(app) + + expect(html).toContain('aria-roledescription="slide"') + expect(html).toContain('role="group"') + }) + }) +}) diff --git a/packages/0/src/components/Carousel/index.ts b/packages/0/src/components/Carousel/index.ts new file mode 100644 index 000000000..81790da7b --- /dev/null +++ b/packages/0/src/components/Carousel/index.ts @@ -0,0 +1,243 @@ +export { provideCarouselRoot, useCarouselRoot } from './CarouselRoot.vue' +export { default as CarouselRoot } from './CarouselRoot.vue' +export { default as CarouselViewport } from './CarouselViewport.vue' +export { default as CarouselItem } from './CarouselItem.vue' +export { default as CarouselPrevious } from './CarouselPrevious.vue' +export { default as CarouselNext } from './CarouselNext.vue' +export { default as CarouselIndicator } from './CarouselIndicator.vue' +export { default as CarouselProgress } from './CarouselProgress.vue' +export { default as CarouselLiveRegion } from './CarouselLiveRegion.vue' + +export type { CarouselContext, CarouselOrientation, CarouselPartTicket, CarouselRootProps, CarouselRootSlotProps, CarouselTicket } from './CarouselRoot.vue' +export type { CarouselViewportProps, CarouselViewportSlotProps } from './CarouselViewport.vue' +export type { CarouselItemProps, CarouselItemSlotProps } from './CarouselItem.vue' +export type { CarouselPreviousProps, CarouselPreviousSlotProps } from './CarouselPrevious.vue' +export type { CarouselNextProps, CarouselNextSlotProps } from './CarouselNext.vue' +export type { CarouselIndicatorItem, CarouselIndicatorProps, CarouselIndicatorSlotProps } from './CarouselIndicator.vue' +export type { CarouselProgressProps, CarouselProgressSlotProps } from './CarouselProgress.vue' +export type { CarouselLiveRegionProps, CarouselLiveRegionSlotProps } from './CarouselLiveRegion.vue' + +// Components +import Indicator from './CarouselIndicator.vue' +import Item from './CarouselItem.vue' +import LiveRegion from './CarouselLiveRegion.vue' +import Next from './CarouselNext.vue' +import Previous from './CarouselPrevious.vue' +import Progress from './CarouselProgress.vue' +import Root from './CarouselRoot.vue' +import Viewport from './CarouselViewport.vue' + +/** + * Carousel component with sub-components for building accessible slide navigation. + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @example + * ```vue + * + * + * + * + * + * + * Slide {{ i }} + * + * + * + * Previous + * Next + * + * + * ``` + */ +export const Carousel = { + /** + * Root component that provides carousel context. + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @example + * ```vue + * + * + * + * + * + * + * Slide {{ i }} + * + * + * + * Previous + * Next + * + * + * + * ``` + */ + Root, + /** + * Scroll-snap container for carousel items. Handles native drag/swipe + * via CSS scroll-snap and synchronizes scroll position with selection. + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @example + * ```vue + * + * + * + * Slide {{ i }} + * + * + * + * + * + * Dragging... + * + * + * ``` + */ + Viewport, + /** + * Individual carousel slide that registers with the step context. + * Exposes selection state, visibility, and ARIA attributes. + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @example + * ```vue + * + * Slide 1 + * + * + * + * + * Slide 2 + * + * + * + * + * + * Slide 3 + * + * + * ``` + */ + Item, + /** + * Navigation button that moves to the previous slide. Automatically + * disables at the first slide in non-circular mode. + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @example + * ```vue + * + * Previous + * + * + * + * ‹ + * + * + * + * + * Previous + * + * + * ``` + */ + Previous, + /** + * Navigation button that moves to the next slide. Automatically + * disables at the last visible boundary in non-circular mode. + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @example + * ```vue + * + * Next + * + * + * + * › + * + * + * + * + * Next + * + * + * ``` + */ + Next, + /** + * Dot indicator navigation showing the active slide. Exposes an + * `items` array via slot props — one entry per slide with selection + * state and ARIA attributes. Uses roving tabindex for keyboard nav. + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @example + * ```vue + * + * + * + * + * + * ``` + */ + Indicator, + /** + * Autoplay progress bar that fills based on remaining timer duration. + * Exposes progress (0–1), autoplay state, and width styling via + * slot props. Use `data-state` to style active/paused/idle states. + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @example + * ```vue + * + * + * + * + * + * + * + * ``` + */ + Progress, + /** + * Visually-hidden live region that announces slide changes to screen + * readers. Should be styled with sr-only/visually-hidden CSS. + * + * @see https://0.vuetifyjs.com/components/semantic/carousel + * + * @example + * ```vue + * + * + * + * + * + * + * ``` + */ + LiveRegion, +} diff --git a/packages/0/src/components/index.ts b/packages/0/src/components/index.ts index 3897e7a90..133761c49 100644 --- a/packages/0/src/components/index.ts +++ b/packages/0/src/components/index.ts @@ -3,6 +3,7 @@ export * from './Atom' export * from './Avatar' export * from './Breadcrumbs' export * from './Button' +export * from './Carousel' export * from './Checkbox' export * from './Collapsible' export * from './Combobox' diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index a731af712..e29a8bbc1 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -496,7 +496,7 @@ "category": "semantic" }, "Carousel": { - "level": "draft", + "level": "preview", "category": "semantic" }, "Image": {