diff --git a/addon/components/o-s-s/feature-card.hbs b/addon/components/o-s-s/feature-card.hbs new file mode 100644 index 000000000..6e0ed40af --- /dev/null +++ b/addon/components/o-s-s/feature-card.hbs @@ -0,0 +1,8 @@ +
+
+ {{@title}} +

{{@description}}

+
+ + {{this.imageAlt}} +
\ No newline at end of file diff --git a/addon/components/o-s-s/feature-card.stories.js b/addon/components/o-s-s/feature-card.stories.js new file mode 100644 index 000000000..eed57a0d3 --- /dev/null +++ b/addon/components/o-s-s/feature-card.stories.js @@ -0,0 +1,90 @@ +import { hbs } from 'ember-cli-htmlbars'; + +const ColorVariants = ['blue', 'violet', 'yellow']; +const ShadowVariants = ['sm', 'lg']; + +export default { + title: 'Components/OSS::FeatureCard', + component: 'feature-card', + argTypes: { + title: { + description: 'Card title', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'text' }, + type: { required: true } + }, + description: { + description: 'Card description', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'text' }, + type: { required: true } + }, + image: { + description: 'Image object including src and optional alt', + table: { + type: { summary: '{ src: string; alt?: string }' }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'object' }, + type: { required: true } + }, + colorVariant: { + description: 'Card background color variant', + table: { + type: { summary: ColorVariants.join('|') }, + defaultValue: { summary: 'violet' } + }, + options: ColorVariants, + control: { type: 'select' } + }, + shadowVariant: { + description: 'Card shadow variant', + table: { + type: { summary: ShadowVariants.join('|') }, + defaultValue: { summary: 'sm' } + }, + options: ShadowVariants, + control: { type: 'select' } + } + }, + parameters: { + docs: { + description: { + component: 'Displays a card with title, description and illustration. It includes color and shadow variants.' + } + } + } +}; + +const defaultArgs = { + title: 'Audience & content insights', + description: 'Pull demographics and media performance into your BI to target smarter and report faster.', + image: { + src: '/@upfluence/oss-components/assets/images/no-image.svg', + alt: 'No image illustration' + }, + colorVariant: 'violet', + shadowVariant: 'lg' +}; + +const Template = (args) => ({ + template: hbs` + + `, + context: args +}); + +export const Default = Template.bind({}); +Default.args = defaultArgs; diff --git a/addon/components/o-s-s/feature-card.ts b/addon/components/o-s-s/feature-card.ts new file mode 100644 index 000000000..d380764bd --- /dev/null +++ b/addon/components/o-s-s/feature-card.ts @@ -0,0 +1,77 @@ +import Component from '@glimmer/component'; +import { assert } from '@ember/debug'; +import { isSafeString } from '@upfluence/oss-components/utils'; +import type { IntlService } from 'ember-intl'; + +export const COLOR_VARIANTS = ['blue', 'violet', 'yellow'] as const; +export type OSSFeatureCardColorVariant = (typeof COLOR_VARIANTS)[number]; +const DEFAULT_COLOR_VARIANT = 'violet' as const satisfies OSSFeatureCardColorVariant; + +export const SHADOW_VARIANTS = ['sm', 'lg'] as const; +export type OSSFeatureCardShadowVariant = (typeof SHADOW_VARIANTS)[number]; +const DEFAULT_SHADOW_VARIANT = 'sm' as const satisfies OSSFeatureCardShadowVariant; + +export type OSSFeatureCardImage = { + src: string; + alt?: string; +}; + +export type OSSFeatureCardArgs = { + title: string | ReturnType; + description: string | ReturnType; + image: OSSFeatureCardImage; + colorVariant?: OSSFeatureCardColorVariant; + shadowVariant?: OSSFeatureCardShadowVariant; +}; + +export default class OSSFeatureCard extends Component { + constructor(owner: unknown, args: OSSFeatureCardArgs) { + super(owner, args); + + assert( + '[OSS::FeatureCard] The @title parameter is mandatory', + (typeof args.title === 'string' || isSafeString(args.title)) && args.title.toString().trim().length > 0 + ); + assert( + '[OSS::FeatureCard] The @description parameter is mandatory', + (typeof args.description === 'string' || isSafeString(args.description)) && + args.description.toString().trim().length > 0 + ); + assert( + '[OSS::FeatureCard] The @image parameter is mandatory and must contain a src key', + typeof args?.image?.src === 'string' && args.image.src.trim().length > 0 + ); + if (args.colorVariant) { + assert( + `[OSS::FeatureCard] @colorVariant must be one of: ${COLOR_VARIANTS.join(', ')}`, + COLOR_VARIANTS.includes(args.colorVariant) + ); + } + if (args.shadowVariant) { + assert( + `[OSS::FeatureCard] @shadowVariant must be one of: ${SHADOW_VARIANTS.join(', ')}`, + SHADOW_VARIANTS.includes(args.shadowVariant) + ); + } + } + + get colorVariant(): OSSFeatureCardColorVariant { + return this.args.colorVariant ?? DEFAULT_COLOR_VARIANT; + } + + get shadowVariant(): OSSFeatureCardShadowVariant { + return this.args.shadowVariant ?? DEFAULT_SHADOW_VARIANT; + } + + get computedClasses(): string { + return [ + 'oss-feature-card', + `oss-feature-card--color-${this.colorVariant}`, + `oss-feature-card--shadow-${this.shadowVariant}` + ].join(' '); + } + + get imageAlt(): string { + return this.args.image.alt ?? ''; + } +} diff --git a/addon/components/o-s-s/feature-cards-container.hbs b/addon/components/o-s-s/feature-cards-container.hbs new file mode 100644 index 000000000..96c6931c6 --- /dev/null +++ b/addon/components/o-s-s/feature-cards-container.hbs @@ -0,0 +1,12 @@ +
+ {{#each this.cardsWithComputedVariants as |card|}} + + {{/each}} +
\ No newline at end of file diff --git a/addon/components/o-s-s/feature-cards-container.stories.js b/addon/components/o-s-s/feature-cards-container.stories.js new file mode 100644 index 000000000..940b6371a --- /dev/null +++ b/addon/components/o-s-s/feature-cards-container.stories.js @@ -0,0 +1,98 @@ +import { hbs } from 'ember-cli-htmlbars'; + +export default { + title: 'Components/OSS::FeatureCardsContainer', + component: 'feature-cards-container', + argTypes: { + cards: { + description: + '1 to 3 feature cards. Uses the same argument shape as OSS::FeatureCard; overlap and rotation are handled by the container. If colorVariant or shadowVariant are omitted, container defaults are applied.', + table: { + type: { + summary: + 'Array<{ title: string; description: string; image: { src: string; alt?: string }; colorVariant?: "blue"|"violet"|"yellow"; shadowVariant?: "sm"|"lg" }>' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'object' }, + type: { required: true } + } + }, + parameters: { + docs: { + description: { + component: + 'Wrapper that lays out 1 to 3 OSS::FeatureCard components with angles and overlap. Card color/shadow values are passed through from each card object, with defaults when variants are omitted. See [OSS::FeatureCard](?path=/story/components-oss-featurecard--default) for individual card details.' + } + } + } +}; + +const defaultImage = { + src: '/@upfluence/oss-components/assets/images/no-image.svg', + alt: 'No image illustration' +}; + +const threeCardsArgs = { + cards: [ + { + title: 'Creator discovery at scale', + description: + 'Discover and enrich creators via API using platform, region, and key attributes to power precise scouting.', + image: defaultImage, + colorVariant: 'blue', + shadowVariant: 'sm' + }, + { + title: 'Audience & content insights', + description: 'Pull demographics and media performance into your BI to target smarter and report faster.', + image: defaultImage, + colorVariant: 'violet', + shadowVariant: 'lg' + }, + { + title: 'Campaign performance tracking', + description: 'Track contribution stages, orders and ROI, then sync results to your CRM.', + image: defaultImage, + colorVariant: 'yellow', + shadowVariant: 'sm' + } + ] +}; + +const twoCardsArgs = { + cards: threeCardsArgs.cards.slice(0, 2) +}; + +const threeCardsWithDefaultsArgs = { + cards: threeCardsArgs.cards.map(({ title, description, image }) => ({ + title, + description, + image + })) +}; + +const oneCardArgs = { + cards: threeCardsArgs.cards.slice(0, 1) +}; + +const Template = (args) => ({ + template: hbs` +
+ +
+ `, + context: args +}); + +export const ThreeCards = Template.bind({}); +ThreeCards.args = threeCardsArgs; + +export const ThreeCardsWithContainerDefaults = Template.bind({}); +ThreeCardsWithContainerDefaults.args = threeCardsWithDefaultsArgs; + +export const TwoCards = Template.bind({}); +TwoCards.args = twoCardsArgs; + +export const OneCard = Template.bind({}); +OneCard.args = oneCardArgs; diff --git a/addon/components/o-s-s/feature-cards-container.ts b/addon/components/o-s-s/feature-cards-container.ts new file mode 100644 index 000000000..ea9e9353b --- /dev/null +++ b/addon/components/o-s-s/feature-cards-container.ts @@ -0,0 +1,56 @@ +import { assert } from '@ember/debug'; +import Component from '@glimmer/component'; + +import type { OSSFeatureCardArgs, OSSFeatureCardColorVariant, OSSFeatureCardShadowVariant } from './feature-card'; + +type OSSFeatureCardsContainerArgs = { + cards: OSSFeatureCardArgs[]; +}; + +function isCenterCard(cardsCount: number, index: number): boolean { + return cardsCount === 1 || (cardsCount === 3 && index === 1); +} + +function getDefaultCardColorVariant(cardsCount: number, index: number): OSSFeatureCardColorVariant { + if (isCenterCard(cardsCount, index)) return 'violet'; + + if (cardsCount === 2 || cardsCount === 3) return index === 0 ? 'blue' : 'yellow'; + + assert('[OSS::FeatureCardsContainer] Internal layout configuration mismatch', false); +} + +function getDefaultCardShadowVariant(cardsCount: number, index: number): OSSFeatureCardShadowVariant { + if (isCenterCard(cardsCount, index)) return 'lg'; + + if (cardsCount === 2 || cardsCount === 3) return 'sm'; + + assert('[OSS::FeatureCardsContainer] Internal layout configuration mismatch', false); +} + +export default class OSSFeatureCardsContainer extends Component { + constructor(owner: unknown, args: OSSFeatureCardsContainerArgs) { + super(owner, args); + + assert('[OSS::FeatureCardsContainer] The @cards parameter is mandatory', Array.isArray(args.cards)); + assert( + '[OSS::FeatureCardsContainer] @cards must contain between 1 and 3 cards', + args.cards.length >= 1 && args.cards.length <= 3 + ); + } + + get cardsWithComputedVariants(): OSSFeatureCardArgs[] { + const cards = this.args.cards ?? []; + + return cards.map((card, index) => { + const cardCount = cards.length; + const colorVariant = card.colorVariant ?? getDefaultCardColorVariant(cardCount, index); + const shadowVariant = card.shadowVariant ?? getDefaultCardShadowVariant(cardCount, index); + + return { + ...card, + colorVariant, + shadowVariant + }; + }); + } +} diff --git a/app/components/o-s-s/feature-card.js b/app/components/o-s-s/feature-card.js new file mode 100644 index 000000000..127673b07 --- /dev/null +++ b/app/components/o-s-s/feature-card.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/feature-card'; diff --git a/app/components/o-s-s/feature-cards-container.js b/app/components/o-s-s/feature-cards-container.js new file mode 100644 index 000000000..6d9cc2829 --- /dev/null +++ b/app/components/o-s-s/feature-cards-container.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/feature-cards-container'; diff --git a/app/styles/molecules/feature-card.less b/app/styles/molecules/feature-card.less new file mode 100644 index 000000000..7f79ae485 --- /dev/null +++ b/app/styles/molecules/feature-card.less @@ -0,0 +1,62 @@ +.oss-feature-card { + display: flex; + flex-direction: column; + gap: var(--spacing-px-24); + align-items: flex-start; + justify-content: center; + max-inline-size: 342px; + padding: var(--spacing-px-30) var(--spacing-px-60); + border: 1px solid; + border-radius: var(--border-radius-md); + font-family: var(--font-family-stack); + + &--color-blue { + background-color: var(--color-blue-50); + border-color: var(--color-blue-100); + } + + &--color-violet { + background-color: var(--color-violet-50); + border-color: var(--color-violet-100); + } + + &--color-yellow { + background-color: var(--color-warning-50); + border-color: var(--color-warning-100); + } + + &--shadow-sm { + box-shadow: var(--box-shadow-sm); + } + + &--shadow-lg { + box-shadow: var(--box-shadow-lg); + } + + &__content { + display: flex; + flex-direction: column; + gap: var(--spacing-px-3); + } + + &__description { + --max-number-of-lines: 4; + + font-size: var(--font-size-sm); + color: var(--color-gray-600); + text-wrap: balance; + margin: 0; + + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: var(--max-number-of-lines); + line-clamp: var(--max-number-of-lines); + } + + &__illustration { + inline-size: 100%; + block-size: auto; + max-block-size: 122px; + object-fit: cover; + } +} diff --git a/app/styles/molecules/feature-cards-container.less b/app/styles/molecules/feature-cards-container.less new file mode 100644 index 000000000..78002fdb7 --- /dev/null +++ b/app/styles/molecules/feature-cards-container.less @@ -0,0 +1,45 @@ +.oss-feature-cards-container { + --two-cards-offset-x: 45%; + --three-cards-offset-x: 80%; + --rotation-angle: 11.25deg; + --center-card-top: 30px; + --side-cards-top: 58px; + + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-block-size: 430px; + inline-size: 100%; + + &__item { + position: absolute; + + // To target child i in a list of n, we can use: :nth-child(i):nth-last-child(n - i + 1) + // Specific 2 cards layout + &:first-child:nth-last-child(2) { + translate: calc(-1 * var(--two-cards-offset-x)); + rotate: calc(-1 * var(--rotation-angle)); + } + &:last-child:nth-child(2) { + translate: var(--two-cards-offset-x); + rotate: var(--rotation-angle); + } + + // Specific 3 cards layout + &:first-child:nth-last-child(3) { + top: var(--side-cards-top); + translate: calc(-1 * var(--three-cards-offset-x)); + rotate: calc(-1 * var(--rotation-angle)); + } + &:nth-child(2):nth-last-child(2) { + top: var(--center-card-top); + z-index: 1; + } + &:last-child:nth-child(3) { + top: var(--side-cards-top); + translate: var(--three-cards-offset-x); + rotate: var(--rotation-angle); + } + } +} diff --git a/app/styles/oss-components.less b/app/styles/oss-components.less index baab44087..ce7f4b0b1 100644 --- a/app/styles/oss-components.less +++ b/app/styles/oss-components.less @@ -71,6 +71,8 @@ @import 'molecules/password-input'; @import 'molecules/avatar-group'; @import 'molecules/stack-container'; +@import 'molecules/feature-card'; +@import 'molecules/feature-cards-container'; @import 'molecules/onboarding-state'; @import 'molecules/infinite-select-option'; @import 'organisms/table'; diff --git a/tests/dummy/app/controllers/visual.ts b/tests/dummy/app/controllers/visual.ts index 02407c670..8da87bdea 100644 --- a/tests/dummy/app/controllers/visual.ts +++ b/tests/dummy/app/controllers/visual.ts @@ -3,6 +3,7 @@ import { tracked } from '@glimmer/tracking'; import { action, set } from '@ember/object'; import type { ModeSwitchOption } from '@upfluence/oss-components/components/o-s-s/mode-switch'; import type { FeedbackMessage } from '@upfluence/oss-components/components/o-s-s/input-container'; +import type { OSSFeatureCardArgs, OSSFeatureCardImage } from '@upfluence/oss-components/components/o-s-s/feature-card'; export default class Visual extends Controller { @tracked toggleValue: boolean = false; @@ -112,6 +113,44 @@ export default class Visual extends Controller { @tracked feedbackMessageWarning: FeedbackMessage = { type: 'warning', value: '' }; @tracked feedbackMessageSuccess: FeedbackMessage = { type: 'success', value: '' }; + featureCardImage: OSSFeatureCardImage = { + src: '/@upfluence/oss-components/assets/images/no-image.svg', + alt: 'No image illustration' + }; + + twoFeatureCards: OSSFeatureCardArgs[] = [ + { + title: 'Creator discovery at scale', + description: + 'Discover and enrich creators via API using platform, region, and key attributes to power precise, data-driven scouting.', + image: this.featureCardImage + }, + { + title: 'Audience & content insights', + description: 'Pull demographics and media performance into your BI to target smarter and report faster.', + image: this.featureCardImage + } + ]; + threeFeatureCards: OSSFeatureCardArgs[] = [ + { + title: 'Creator discovery at scale', + description: + 'Discover and enrich creators via API using platform, region, and key attributes to power precise, data-driven scouting.', + image: this.featureCardImage + }, + { + title: 'Audience & content insights', + description: 'Pull demographics and media performance into your BI to target smarter and report faster.', + image: this.featureCardImage + }, + { + title: 'Campaign performance tracking', + description: 'Track contribution stages, orders and discount-code ROI, then sync results to your CRM.', + image: this.featureCardImage + } + ]; + featureCard: OSSFeatureCardArgs = this.threeFeatureCards[1]!; + @tracked progressBarSuccess: number = 30; @tracked progressBarWarning: number = 25; @tracked progressBarDanger: number = 15; diff --git a/tests/dummy/app/templates/visual.hbs b/tests/dummy/app/templates/visual.hbs index 1f336ff28..e6950b2e3 100644 --- a/tests/dummy/app/templates/visual.hbs +++ b/tests/dummy/app/templates/visual.hbs @@ -949,4 +949,29 @@ + +
+
+ Feature cards +
+
+
+ +
+ +
+ +
+
+ +
+
+
\ No newline at end of file diff --git a/tests/integration/components/o-s-s/feature-card-test.ts b/tests/integration/components/o-s-s/feature-card-test.ts new file mode 100644 index 000000000..834282065 --- /dev/null +++ b/tests/integration/components/o-s-s/feature-card-test.ts @@ -0,0 +1,136 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, setupOnerror } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { COLOR_VARIANTS, SHADOW_VARIANTS } from '@upfluence/oss-components/components/o-s-s/feature-card'; +import { setupIntl } from 'ember-intl/test-support'; + +module('Integration | Component | o-s-s/feature-card', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks); + + hooks.beforeEach(function () { + this.title = this.intl.t('Audience & content insights', { htmlSafe: true }); + this.description = 'Pull demographics and media performance into your BI to target smarter and report faster.'; + this.image = { + src: '/@upfluence/oss-components/assets/images/no-image.svg', + alt: 'No image illustration' + }; + }); + + module('Rendering', () => { + test('it renders the title', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.oss-feature-card').exists(); + assert.dom('.oss-feature-card>:first-child>:first-child').hasText(this.title.toString()); + }); + + test('it renders the description', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.oss-feature-card').exists(); + assert.dom('.oss-feature-card__description').hasText(this.description); + }); + + test('it renders the image', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.oss-feature-card').exists(); + assert.dom('.oss-feature-card__illustration').hasAttribute('src', this.image.src); + assert.dom('.oss-feature-card__illustration').hasAttribute('alt', this.image.alt); + }); + + test('it renders extra attributes (splattributes)', async function (assert) { + await render( + hbs`` + ); + + assert.dom('[data-control-name="feature-card-test"]').exists(); + }); + }); + + module('Variants', () => { + COLOR_VARIANTS.forEach((colorVariant) => { + test(`it supports the ${colorVariant} color variant`, async function (assert) { + this.colorVariant = colorVariant; + + await render( + hbs`` + ); + + assert.dom('.oss-feature-card').hasClass(`oss-feature-card--color-${colorVariant}`); + }); + }); + + SHADOW_VARIANTS.forEach((shadowVariant) => { + test(`it supports the ${shadowVariant} shadow variant`, async function (assert) { + this.shadowVariant = shadowVariant; + + await render( + hbs`` + ); + + assert.dom('.oss-feature-card').hasClass(`oss-feature-card--shadow-${shadowVariant}`); + }); + }); + }); + + module('Error management', () => { + test('it throws when @title is missing', async function (assert) { + setupOnerror((err: any) => { + assert.equal(err.message, 'Assertion Failed: [OSS::FeatureCard] The @title parameter is mandatory'); + }); + + await render(hbs``); + }); + + test('it throws when @description is missing', async function (assert) { + setupOnerror((err: any) => { + assert.equal(err.message, 'Assertion Failed: [OSS::FeatureCard] The @description parameter is mandatory'); + }); + + await render(hbs``); + }); + + test('it throws when @image is missing', async function (assert) { + setupOnerror((err: any) => { + assert.equal( + err.message, + 'Assertion Failed: [OSS::FeatureCard] The @image parameter is mandatory and must contain a src key' + ); + }); + + await render(hbs``); + }); + + test('it throws when @colorVariant is unknown', async function (assert) { + setupOnerror((err: any) => { + assert.equal( + err.message, + 'Assertion Failed: [OSS::FeatureCard] @colorVariant must be one of: blue, violet, yellow' + ); + }); + + await render( + hbs`` + ); + }); + + test('it throws when @shadowVariant is unknown', async function (assert) { + setupOnerror((err: any) => { + assert.equal(err.message, 'Assertion Failed: [OSS::FeatureCard] @shadowVariant must be one of: sm, lg'); + }); + + await render( + hbs`` + ); + }); + }); +}); diff --git a/tests/integration/components/o-s-s/feature-cards-container-test.ts b/tests/integration/components/o-s-s/feature-cards-container-test.ts new file mode 100644 index 000000000..00b25516c --- /dev/null +++ b/tests/integration/components/o-s-s/feature-cards-container-test.ts @@ -0,0 +1,150 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, setupOnerror } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import type { OSSFeatureCardArgs } from '@upfluence/oss-components/components/o-s-s/feature-card'; + +module('Integration | Component | o-s-s/feature-cards-container', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.image = { + src: '/@upfluence/oss-components/assets/images/no-image.svg', + alt: 'No image illustration' + }; + this.cards = [ + { + title: 'Creator discovery at scale', + description: 'Discover and enrich creators via API.', + image: this.image, + colorVariant: 'yellow', + shadowVariant: 'lg' + }, + { + title: 'Audience & content insights', + description: 'Pull demographics and media performance into your BI.', + image: this.image, + colorVariant: 'blue' + }, + { + title: 'Campaign performance tracking', + description: 'Track contribution stages and ROI.', + image: this.image + } + ]; + + this.defaultedCards = this.cards.map((card: OSSFeatureCardArgs) => ({ + title: card.title, + description: card.description, + image: card.image + })); + }); + + test('it renders', async function (assert) { + this.cards = this.cards.slice(0, 3); + + await render(hbs``); + + assert.dom('.oss-feature-cards-container').exists(); + assert.dom('.oss-feature-cards-container .oss-feature-card').exists({ count: 3 }); + }); + + test('it supports 1 card layout', async function (assert) { + this.cards = this.cards.slice(0, 1); + + await render(hbs``); + + assert.dom('.oss-feature-cards-container .oss-feature-card').exists({ count: 1 }); + assert + .dom('.oss-feature-cards-container>:first-child') + .hasClass('oss-feature-cards-container__item') + .hasClass('oss-feature-card--color-yellow') + .hasClass('oss-feature-card--shadow-lg'); + }); + + test('it supports 2 cards layout', async function (assert) { + this.set('cards', this.cards.slice(0, 2)); + + await render(hbs``); + + assert.dom('.oss-feature-cards-container .oss-feature-card').exists({ count: 2 }); + + assert + .dom('.oss-feature-cards-container>:first-child') + .hasClass('oss-feature-card--color-yellow') + .hasClass('oss-feature-card--shadow-lg'); + + assert + .dom('.oss-feature-cards-container>:last-child') + .hasClass('oss-feature-card--color-blue') + .hasClass('oss-feature-card--shadow-sm'); + }); + + test('it applies computed layout rules for 3 cards', async function (assert) { + this.cards = this.cards.slice(0, 3); + + await render(hbs``); + + assert + .dom('.oss-feature-cards-container>:first-child') + .hasClass('oss-feature-card--color-yellow') + .hasClass('oss-feature-card--shadow-lg'); + + assert + .dom('.oss-feature-cards-container>:nth-child(2)') + .hasClass('oss-feature-cards-container__item') + .hasClass('oss-feature-card--color-blue') + .hasClass('oss-feature-card--shadow-lg'); + + assert + .dom('.oss-feature-cards-container>:last-child') + .hasClass('oss-feature-card--color-yellow') + .hasClass('oss-feature-card--shadow-sm'); + }); + + test('it sets default color and shadow variants when missing', async function (assert) { + this.cards = this.defaultedCards; + + await render(hbs``); + + assert.dom('.oss-feature-cards-container .oss-feature-card').exists({ count: 3 }); + + assert + .dom('.oss-feature-cards-container>:nth-child(1)') + .hasClass('oss-feature-card--color-blue') + .hasClass('oss-feature-card--shadow-sm'); + + assert + .dom('.oss-feature-cards-container>:nth-child(2)') + .hasClass('oss-feature-card--color-violet') + .hasClass('oss-feature-card--shadow-lg'); + + assert + .dom('.oss-feature-cards-container>:nth-child(3)') + .hasClass('oss-feature-card--color-yellow') + .hasClass('oss-feature-card--shadow-sm'); + }); + + module('Error management', () => { + test('it throws when @cards is missing', async function (assert) { + setupOnerror((err: any) => { + assert.equal(err.message, 'Assertion Failed: [OSS::FeatureCardsContainer] The @cards parameter is mandatory'); + }); + + await render(hbs``); + }); + + test('it throws when @cards has invalid count', async function (assert) { + this.cards = []; + + setupOnerror((err: any) => { + assert.equal( + err.message, + 'Assertion Failed: [OSS::FeatureCardsContainer] @cards must contain between 1 and 3 cards' + ); + }); + + await render(hbs``); + }); + }); +});