Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions src/modules/home/components/home.features.component.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
<!--
HomeCardsComponent
==================
Carousel of cards with image, text, and button. Supports sliding and styling.
Carousel or grid of cards with image, text, and button. Supports sliding and styling.

USAGE:
<homeCardsComponent :setup="config.home.repos" />

CONFIG EXAMPLE (setup object):
repos: {
title: 'Our Products',
layout: 'carousel', // 'carousel' (default) or 'grid'
cols: 2, // grid only — 2, 3, 4, or 6 (default: auto from item count)
slide: {
interval: 15000, // Auto-slide interval in ms
interval: 15000, // Auto-slide interval in ms (carousel only)
},
style: {
section: { background: 'surface' },
Expand Down Expand Up @@ -45,9 +47,25 @@
<v-col cols="12">
<homeContentComponent :setup="setup"></homeContentComponent>
</v-col>
<v-col cols="12">
<!-- Grid layout -->
<template v-if="isGrid">
<v-col v-for="(item, i) in setup.content" :key="i" cols="12" :md="item.fullWidth ? 12 : gridColSize">
<v-card :class="`${config.vuetify.theme.rounded}`" :flat="config.vuetify.theme.flat" :style="style('card', { style: item.style })">
<homeImgComponent v-if="item.img && !item.reversed" :img="item.img" :img-mode="item.imgMode"></homeImgComponent>
<homeContentComponent
:setup="item"
:alignment="item.alignment || 'center'"
:color="item.color || 'default'"
variant="card"
></homeContentComponent>
<homeImgComponent v-if="item.img && item.reversed" :img="item.img" :img-mode="item.imgMode"></homeImgComponent>
</v-card>
</v-col>
</template>
<!-- Carousel layout (default) -->
<v-col v-if="!isGrid" cols="12">
Comment thread
PierreBrisorgueil marked this conversation as resolved.
<v-carousel
v-if="setup.content.length > 0"
v-if="setup.content?.length > 0"
v-model="step"
cycle
height="100%"
Expand Down Expand Up @@ -75,7 +93,7 @@
</v-carousel-item>
</v-carousel>
</v-col>
<homeDynamicIsland v-if="steps > 0" :container="cardsContainer" :step="step" :steps="steps" :action="stepper"></homeDynamicIsland>
<homeDynamicIsland v-if="!isGrid && steps > 0" :container="cardsContainer" :step="step" :steps="steps" :action="stepper"></homeDynamicIsland>
</v-row>
</v-container>
</section>
Expand Down Expand Up @@ -116,6 +134,17 @@ export default {
};
},
computed: {
isGrid() {
return this.setup.layout === 'grid';
},
gridColSize() {
const cols = Number(this.setup.cols);
if (!Number.isNaN(cols) && [2, 3, 4, 6].includes(cols)) return 12 / cols;
const count = this.setup.content?.length || 0;
if (count <= 2) return 6;
if (count <= 3) return 4;
return 3;
},
Comment thread
PierreBrisorgueil marked this conversation as resolved.
variant() {
return this.setup.variant || 'default';
},
Expand All @@ -134,12 +163,14 @@ export default {
};
},
steps() {
return this.$vuetify.display.smAndDown ? this.setup.content.length - 1 : Math.ceil(this.setup.content.length / 2) - 1;
const items = this.setup.content || [];
return this.$vuetify.display.smAndDown ? items.length - 1 : Math.ceil(items.length / 2) - 1;
},
content() {
const items = this.setup.content || [];
return this.$vuetify.display.smAndDown
? this.setup.content.slice(this.step, this.step + 1)
: this.setup.content.slice(this.step * 2, this.step * 2 + 2);
? items.slice(this.step, this.step + 1)
: items.slice(this.step * 2, this.step * 2 + 2);
},
},
mounted() {
Expand Down
272 changes: 272 additions & 0 deletions src/modules/home/tests/home.features.component.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import { beforeEach, describe, it, expect, vi } from 'vitest';
import HomeFeaturesComponent from '../components/home.features.component.vue';

/**
* Mock child components.
*/
vi.mock('../components/utils/home.content.component.vue', () => ({
default: { name: 'homeContentComponent', template: '<div class="mock-content" />', props: ['setup', 'alignment', 'color', 'variant'] },
}));
vi.mock('../components/utils/home.dynamicIsland.component.vue', () => ({
default: { name: 'homeDynamicIsland', template: '<div class="mock-island" />', props: ['container', 'step', 'steps', 'action'] },
}));
vi.mock('../components/utils/home.img.component.vue', () => ({
default: { name: 'homeImgComponent', template: '<div class="mock-img" />', props: ['img', 'imgMode'] },
}));

/**
* Mock theme helpers.
*/
vi.mock('../../../lib/helpers/theme', () => ({
style: vi.fn(() => ({ padding: '0' })),
overlapStyle: vi.fn(() => ({})),
colorModeStyle: vi.fn(() => ({})),
}));

const mockConfig = {
vuetify: { theme: { rounded: 'rounded-xl', maxWidth: '1200px', flat: false } },
};

/**
* Build global options with Vuetify + config global property.
* @param {object} vuetify - Vuetify instance.
* @returns {object} Vue test-utils global config.
*/
const globalOpts = (vuetify) => ({
plugins: [vuetify],
config: {
globalProperties: { config: mockConfig },
},
});

const makeItem = (overrides = {}) => ({
subtitle: 'Feature',
img: '/images/feature.webp',
text: 'Description',
...overrides,
});

describe('HomeFeaturesComponent', () => {
let vuetify;

beforeEach(() => {
vuetify = createVuetify({ components, directives });
});

const carouselSetup = {
title: 'Features',
slide: { interval: 5000 },
content: [makeItem({ subtitle: 'A' }), makeItem({ subtitle: 'B' })],
};

const gridSetup = {
title: 'Features',
layout: 'grid',
slide: { interval: 5000 },
content: [makeItem({ subtitle: 'A' }), makeItem({ subtitle: 'B' }), makeItem({ subtitle: 'C' })],
};

// --- Layout detection ---

it('defaults to carousel layout when layout is not specified', () => {
const wrapper = mount(HomeFeaturesComponent, {
props: { setup: carouselSetup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.isGrid).toBe(false);
});

it('detects grid layout when layout is "grid"', () => {
const wrapper = mount(HomeFeaturesComponent, {
props: { setup: gridSetup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.isGrid).toBe(true);
});

// --- Grid rendering ---

it('renders grid cards when layout is grid', () => {
const wrapper = mount(HomeFeaturesComponent, {
props: { setup: gridSetup },
global: globalOpts(vuetify),
});
const cards = wrapper.findAllComponents({ name: 'VCard' });
expect(cards).toHaveLength(3);
});

it('does not render carousel when layout is grid', () => {
const wrapper = mount(HomeFeaturesComponent, {
props: { setup: gridSetup },
global: globalOpts(vuetify),
});
const carousel = wrapper.findComponent({ name: 'VCarousel' });
expect(carousel.exists()).toBe(false);
});

it('does not render dynamic island when layout is grid', () => {
const wrapper = mount(HomeFeaturesComponent, {
props: { setup: gridSetup },
global: globalOpts(vuetify),
});
const island = wrapper.findComponent({ name: 'homeDynamicIsland' });
expect(island.exists()).toBe(false);
});

// --- Grid column sizing ---

it('auto-computes gridColSize=6 for 2 items', () => {
const setup = { ...gridSetup, content: [makeItem(), makeItem()] };
const wrapper = mount(HomeFeaturesComponent, {
props: { setup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.gridColSize).toBe(6);
});

it('auto-computes gridColSize=4 for 3 items', () => {
const wrapper = mount(HomeFeaturesComponent, {
props: { setup: gridSetup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.gridColSize).toBe(4);
});

it('auto-computes gridColSize=3 for 4+ items', () => {
const setup = { ...gridSetup, content: [makeItem(), makeItem(), makeItem(), makeItem()] };
const wrapper = mount(HomeFeaturesComponent, {
props: { setup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.gridColSize).toBe(3);
});

it('uses explicit cols when provided', () => {
const setup = { ...gridSetup, cols: 2 };
const wrapper = mount(HomeFeaturesComponent, {
props: { setup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.gridColSize).toBe(6);
});

it('uses explicit cols=3 for 4-col grid', () => {
const setup = { ...gridSetup, cols: 3 };
const wrapper = mount(HomeFeaturesComponent, {
props: { setup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.gridColSize).toBe(4);
});

it('coerces string cols from env var config', () => {
const setup = { ...gridSetup, cols: '4' };
const wrapper = mount(HomeFeaturesComponent, {
props: { setup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.gridColSize).toBe(3);
});

it('falls back to auto when cols is an invalid string', () => {
const setup = { ...gridSetup, cols: 'abc' };
const wrapper = mount(HomeFeaturesComponent, {
props: { setup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.gridColSize).toBe(4); // 3 items → auto 4
});

it('falls back to auto when cols is not a valid divisor', () => {
const setup = { ...gridSetup, cols: 5 };
const wrapper = mount(HomeFeaturesComponent, {
props: { setup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.gridColSize).toBe(4); // 3 items → auto 4
});

// --- fullWidth in grid ---

it('honors fullWidth in grid layout', () => {
const setup = {
...gridSetup,
content: [makeItem({ fullWidth: true }), makeItem()],
};
const wrapper = mount(HomeFeaturesComponent, {
props: { setup },
global: globalOpts(vuetify),
});
const cols = wrapper.findAllComponents({ name: 'VCol' });
// First col after the header col should have md=12 (fullWidth)
// cols[0] is the header, cols[1] is the fullWidth item, cols[2] is the normal item
const fullWidthCol = cols[1];
const normalCol = cols[2];
expect(fullWidthCol.props('md')).toBe(12);
expect(normalCol.props('md')).toBe(6);
});

// --- Carousel rendering (default) ---

it('renders carousel when layout is not grid', () => {
const wrapper = mount(HomeFeaturesComponent, {
props: { setup: carouselSetup },
global: globalOpts(vuetify),
});
const carousel = wrapper.findComponent({ name: 'VCarousel' });
expect(carousel.exists()).toBe(true);
});

// --- Null-safety ---

it('handles undefined content gracefully in grid mode', () => {
const setup = { title: 'Features', layout: 'grid', slide: { interval: 5000 } };
const wrapper = mount(HomeFeaturesComponent, {
props: { setup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.gridColSize).toBe(6);
expect(wrapper.vm.isGrid).toBe(true);
});

it('handles undefined content gracefully in carousel mode', () => {
const setup = { title: 'Features', slide: { interval: 5000 } };
const wrapper = mount(HomeFeaturesComponent, {
props: { setup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.steps).toBe(-1);
expect(wrapper.vm.content).toEqual([]);
});

// --- Computed properties ---

it('uses default variant when not specified', () => {
const wrapper = mount(HomeFeaturesComponent, {
props: { setup: carouselSetup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.variant).toBe('default');
});

it('uses alternate variant when specified', () => {
const setup = { ...carouselSetup, variant: 'alternate' };
const wrapper = mount(HomeFeaturesComponent, {
props: { setup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.variant).toBe('alternate');
});

it('computes containerStyle with maxWidth from config', () => {
const wrapper = mount(HomeFeaturesComponent, {
props: { setup: carouselSetup },
global: globalOpts(vuetify),
});
expect(wrapper.vm.containerStyle['max-width']).toBe('1200px');
});
});
Loading