@mozaic-ds/vue
Version:
Mozaic-Vue is the Vue.js implementation of ADEO Design system
281 lines (242 loc) • 9.05 kB
text/typescript
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { nextTick } from 'vue';
import MCarousel from './MCarousel.vue';
import MIconButton from '../iconbutton/MIconButton.vue';
import { ChevronLeft20, ChevronRight20 } from '@mozaic-ds/icons-vue';
/* eslint-disable @typescript-eslint/no-explicit-any */
class MockResizeObserver {
callback: any;
constructor(callback: any) {
this.callback = callback;
}
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
describe('MCarousel component', () => {
let originalResizeObserver: any;
beforeAll(() => {
originalResizeObserver = global.ResizeObserver;
global.ResizeObserver = MockResizeObserver as any;
Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', {
value: vi.fn(),
writable: true,
});
// Mock getBoundingClientRect for visibility tests
window.HTMLElement.prototype.getBoundingClientRect = vi.fn(() => ({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => {},
}));
});
afterAll(() => {
global.ResizeObserver = originalResizeObserver;
});
const mockChildren = [
'<div class="slide">Slide 1</div>',
'<div class="slide">Slide 2</div>',
'<div class="slide">Slide 3</div>',
];
const mountCarousel = (options = {}) =>
mount(MCarousel, {
attachTo: document.body,
slots: {
default: mockChildren.join(''),
header: '<h2 id="mc-carousel__title">Carousel Header</h2>',
},
...options,
});
it('renders correctly with header and default slot', () => {
const wrapper = mountCarousel();
expect(wrapper.find('.mc-carousel__headings').text()).toContain(
'Carousel Header',
);
expect(wrapper.findAll('.slide')).toHaveLength(3);
});
it('renders navigation buttons with correct aria labels', () => {
const wrapper = mountCarousel({
props: {
previousButtonAriaLabel: 'Go back',
nextButtonAriaLabel: 'Go forward',
},
});
const buttons = wrapper.findAllComponents(MIconButton);
expect(buttons).toHaveLength(2);
expect(buttons[0].attributes('aria-label')).toBe('Go back');
expect(buttons[1].attributes('aria-label')).toBe('Go forward');
});
it('renders default aria labels when not provided', () => {
const wrapper = mountCarousel();
const buttons = wrapper.findAllComponents(MIconButton);
expect(buttons[0].attributes('aria-label')).toBe('previous');
expect(buttons[1].attributes('aria-label')).toBe('next');
});
it('renders icon components inside navigation buttons', () => {
const wrapper = mountCarousel();
expect(wrapper.findComponent(ChevronLeft20).exists()).toBe(true);
expect(wrapper.findComponent(ChevronRight20).exists()).toBe(true);
});
it('disables the previous button when on the first slide', async () => {
const wrapper = mountCarousel();
// Mock container at start position
const contentContainer = wrapper.find('.mc-carousel__content');
Object.defineProperty(contentContainer.element, 'scrollLeft', {
value: 0,
writable: true,
configurable: true,
});
Object.defineProperty(contentContainer.element, 'scrollWidth', {
value: 300,
writable: true,
configurable: true,
});
Object.defineProperty(contentContainer.element, 'clientWidth', {
value: 100,
writable: true,
configurable: true,
});
// Trigger scroll to update state
await contentContainer.trigger('scroll');
await nextTick();
const [prevButton] = wrapper.findAllComponents(MIconButton);
expect(prevButton.props('disabled')).toBe(true);
});
it('enables next button when not on last slide', async () => {
const wrapper = mountCarousel({
slots: {
default:
'<div class="slide">Slide 1</div><div class="slide">Slide 2</div><div class="slide">Slide 3</div>',
header: '<h2 id="mc-carousel__title">Carousel Header</h2>',
},
});
// Mock container with scrollable content (on first slide)
const contentContainer = wrapper.find('.mc-carousel__content');
Object.defineProperty(contentContainer.element, 'scrollLeft', {
value: 0,
writable: true,
configurable: true,
});
Object.defineProperty(contentContainer.element, 'scrollWidth', {
value: 300,
writable: true,
configurable: true,
});
Object.defineProperty(contentContainer.element, 'clientWidth', {
value: 100,
writable: true,
configurable: true,
});
// Trigger scroll to update state
await contentContainer.trigger('scroll');
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 200));
const buttons = wrapper.findAllComponents(MIconButton);
const nextButton = buttons[1];
expect(nextButton.props('disabled')).toBe(false);
});
it('sets correct ARIA attributes on main container', () => {
const wrapper = mountCarousel();
const container = wrapper.find('.mc-carousel');
expect(container.attributes('role')).toBe('group');
expect(container.attributes('aria-roledescription')).toBe('carousel');
// aria-labelledby should point to the headings wrapper (dynamic id)
const labelledby = container.attributes('aria-labelledby');
expect(labelledby).toBeDefined();
expect(wrapper.find(`#${labelledby}`).exists()).toBe(true);
});
it('slide container has aria-live="polite"', () => {
const wrapper = mountCarousel();
const content = wrapper.find('.mc-carousel__content');
expect(content.attributes('aria-live')).toBe('polite');
});
it('disables next button when there is only one child', async () => {
const wrapper = mountCarousel({
slots: {
default: '<div class="slide">Slide 1</div>',
header: '<h2 id="mc-carousel__title">Carousel Header</h2>',
},
});
// Wait for component to initialize
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 100));
const buttons = wrapper.findAllComponents(MIconButton);
const nextButton = buttons[1];
// Next button should be disabled when there's only one child (which is the last)
expect(nextButton.props('disabled')).toBe(true);
});
it('enables next button when there are multiple children and not on last', async () => {
const wrapper = mountCarousel({
slots: {
default:
'<div class="slide">Slide 1</div><div class="slide">Slide 2</div>',
header: '<h2 id="mc-carousel__title">Carousel Header</h2>',
},
});
// Mock container to simulate being on first slide with more content to scroll
const contentContainer = wrapper.find('.mc-carousel__content');
Object.defineProperty(contentContainer.element, 'scrollLeft', {
value: 0,
writable: true,
configurable: true,
});
Object.defineProperty(contentContainer.element, 'scrollWidth', {
value: 200,
writable: true,
configurable: true,
});
Object.defineProperty(contentContainer.element, 'clientWidth', {
value: 100,
writable: true,
configurable: true,
});
// Trigger scroll to update state
await contentContainer.trigger('scroll');
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 200));
const buttons = wrapper.findAllComponents(MIconButton);
const nextButton = buttons[1];
// Next button should be enabled when on first slide of two
expect(nextButton.props('disabled')).toBe(false);
});
it('disables next button when on last child even with remaining scroll space', async () => {
const wrapper = mountCarousel({
slots: {
default: '<div class="slide" style="width: 200px;">Wide Slide</div>',
header: '<h2 id="mc-carousel__title">Carousel Header</h2>',
},
});
// Mock container with a wide child that can still scroll but is the only/last child
const contentContainer = wrapper.find('.mc-carousel__content');
Object.defineProperty(contentContainer.element, 'scrollLeft', {
value: 50,
writable: true,
configurable: true,
});
Object.defineProperty(contentContainer.element, 'scrollWidth', {
value: 200,
writable: true,
configurable: true,
});
Object.defineProperty(contentContainer.element, 'clientWidth', {
value: 100,
writable: true,
configurable: true,
});
// Trigger scroll to update state
await contentContainer.trigger('scroll');
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 200));
const buttons = wrapper.findAllComponents(MIconButton);
const nextButton = buttons[1];
// Should be disabled because we're on the last (only) child
// even though scrollLeft (50) < scrollWidth - clientWidth (100)
expect(nextButton.props('disabled')).toBe(true);
});
});