diff --git a/feature-libs/product-configurator/rulebased/components/overview-bundle-attribute/configurator-overview-bundle-attribute.component.spec.ts b/feature-libs/product-configurator/rulebased/components/overview-bundle-attribute/configurator-overview-bundle-attribute.component.spec.ts index 9c367b21dfb..d3837e89a25 100644 --- a/feature-libs/product-configurator/rulebased/components/overview-bundle-attribute/configurator-overview-bundle-attribute.component.spec.ts +++ b/feature-libs/product-configurator/rulebased/components/overview-bundle-attribute/configurator-overview-bundle-attribute.component.spec.ts @@ -2,6 +2,7 @@ import { Component, Input, Pipe, PipeTransform } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { + FeatureConfigService, I18nTestingModule, ImageType, Product, @@ -64,6 +65,7 @@ describe('ConfiguratorOverviewBundleAttributeComponent', () => { let component: ConfiguratorOverviewBundleAttributeComponent; let fixture: ComponentFixture; let htmlElem: HTMLElement; + let featureConfigService: FeatureConfigService; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -73,7 +75,10 @@ describe('ConfiguratorOverviewBundleAttributeComponent', () => { MockConfiguratorPriceComponent, MockNumericPipe, ], - providers: [{ provide: ProductService, useClass: MockProductService }], + providers: [ + { provide: ProductService, useClass: MockProductService }, + FeatureConfigService, + ], }).compileComponents(); })); @@ -83,6 +88,7 @@ describe('ConfiguratorOverviewBundleAttributeComponent', () => { ); component = fixture.componentInstance; htmlElem = fixture.nativeElement; + featureConfigService = TestBed.inject(FeatureConfigService); }); beforeEach(() => { @@ -142,6 +148,8 @@ describe('ConfiguratorOverviewBundleAttributeComponent', () => { describe('product image', () => { it('should be visible if primary', () => { + spyOn(featureConfigService, 'isEnabled').and.returnValue(true); + product$.next(mockProduct); fixture.detectChanges(); diff --git a/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts b/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts index 5c0757de1ff..1609200e2f5 100644 --- a/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts +++ b/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts @@ -638,6 +638,35 @@ export interface FeatureTogglesInterface { * in the future together with this feature toggle. */ allPageMetaResolversEnabledInCsr?: boolean; + + /** + * When enabled, allows to provide extended formats and media queries for element if used in MediaComponent. + * + * Important: After activation default HTML element in MediaComponent will be `` + * Only BannerComponent has passed `'picture'` value. If you need to use `` HTML element + * you need to pass `[elementType]="'picture'"` to `` + * + * For proper work requires `pictureElementFormats` provided in media config: + * ```ts + * provideConfig({ + * pictureElementFormats: { + * mediaQueries: { + * 'max-width': '767px', + * ... + * }, + * width: 50, + * height: 50, + * }, + * }) + * ``` + * + * After activating this toggle, new inputs in `MediaComponent` — specifically + * `width`, `height`, and `sizes` — will be passed to the template as HTML attributes. + * + * Toggle activates `@Input() elementType: 'img' | 'picture' = 'img'` in `MediaComponent` + * + */ + useExtendedMediaComponentConfiguration?: boolean; } export const defaultFeatureToggles: Required = { @@ -738,4 +767,5 @@ export const defaultFeatureToggles: Required = { enableConsecutiveCharactersPasswordRequirement: false, enablePasswordsCannotMatchInPasswordUpdateForm: false, allPageMetaResolversEnabledInCsr: false, + useExtendedMediaComponentConfiguration: false, }; diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/notification.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/notification.ts index c6af91ae4b1..ed0946dbd88 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/notification.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/notification.ts @@ -283,7 +283,7 @@ export function navigateToPDPInCustomerInterest(productCode: string) { cy.get('.cx-product-interests-product-item').within(() => { cy.get('.cx-code').should('contain', productCode); cy.get( - '.cx-product-interests-product-image-link > .is-initialized > picture' + '.cx-product-interests-product-image-link > .is-initialized > img' ).click(); }); } diff --git a/projects/storefrontapp/src/app/app.module.ts b/projects/storefrontapp/src/app/app.module.ts index 812c67a3a8a..0db5940588f 100644 --- a/projects/storefrontapp/src/app/app.module.ts +++ b/projects/storefrontapp/src/app/app.module.ts @@ -22,17 +22,13 @@ import { translationChunksConfig, translations } from '@spartacus/assets'; import { I18nConfig, OccConfig, - provideConfig, RoutingConfig, TestConfigModule, + provideConfig, } from '@spartacus/core'; import { StoreFinderConfig } from '@spartacus/storefinder/core'; import { GOOGLE_MAPS_DEVELOPMENT_KEY_CONFIG } from '@spartacus/storefinder/root'; -import { - AppRoutingModule, - StorefrontComponent, - USE_LEGACY_MEDIA_COMPONENT, -} from '@spartacus/storefront'; +import { AppRoutingModule, StorefrontComponent } from '@spartacus/storefront'; import { environment } from '../environments/environment'; import { TestOutletModule } from '../test-outlets/test-outlet.module'; import { SpartacusModule } from './spartacus/spartacus.module'; @@ -94,10 +90,6 @@ if (!environment.production) { // without a key, for development or demo purposes. googleMaps: { apiKey: GOOGLE_MAPS_DEVELOPMENT_KEY_CONFIG }, }), - { - provide: USE_LEGACY_MEDIA_COMPONENT, - useValue: false, - }, ], bootstrap: [StorefrontComponent], }) diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts index d05893f4f9d..766e942f566 100644 --- a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts +++ b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts @@ -382,6 +382,7 @@ if (environment.cpq) { enableConsecutiveCharactersPasswordRequirement: true, enablePasswordsCannotMatchInPasswordUpdateForm: true, allPageMetaResolversEnabledInCsr: true, + useExtendedMediaComponentConfiguration: true, }; return appFeatureToggles; }), diff --git a/projects/storefrontlib/cms-components/content/banner/banner.component.html b/projects/storefrontlib/cms-components/content/banner/banner.component.html index f8bb4414150..9eed7874396 100644 --- a/projects/storefrontlib/cms-components/content/banner/banner.component.html +++ b/projects/storefrontlib/cms-components/content/banner/banner.component.html @@ -10,7 +10,15 @@ >

- + +

@@ -21,14 +29,30 @@ [target]="getTarget(data)" >

- + +

diff --git a/projects/storefrontlib/cms-components/content/banner/banner.component.spec.ts b/projects/storefrontlib/cms-components/content/banner/banner.component.spec.ts index 7a94df364cc..73a1b2f919c 100644 --- a/projects/storefrontlib/cms-components/content/banner/banner.component.spec.ts +++ b/projects/storefrontlib/cms-components/content/banner/banner.component.spec.ts @@ -12,6 +12,7 @@ import { SemanticPathService, UrlCommand, } from '@spartacus/core'; +import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { CmsComponentData } from '../../../cms-structure/page/model/cms-component-data'; import { GenericLinkComponent } from '../../../shared/components/generic-link/generic-link.component'; @@ -81,7 +82,12 @@ describe('BannerComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [RouterTestingModule, FeaturesConfigModule], - declarations: [BannerComponent, MockMediaComponent, GenericLinkComponent], + declarations: [ + BannerComponent, + MockMediaComponent, + GenericLinkComponent, + MockFeatureDirective, + ], providers: [ { provide: CmsComponentData, diff --git a/projects/storefrontlib/recipes/config/default-media.config.ts b/projects/storefrontlib/recipes/config/default-media.config.ts index 0df19d9ea29..eb64a90671f 100644 --- a/projects/storefrontlib/recipes/config/default-media.config.ts +++ b/projects/storefrontlib/recipes/config/default-media.config.ts @@ -18,4 +18,19 @@ export const mediaConfig: MediaConfig = { product: { width: 284 }, zoom: { width: 515 }, }, + pictureElementFormats: { + mobile: { + mediaQueries: '(max-width: 767px)', + }, + tablet: { + mediaQueries: '(min-width: 768px) and (max-width: 1024px)', + }, + desktop: { + mediaQueries: '(min-width: 1025px) and (max-width: 1439px)', + }, + widescreen: { + mediaQueries: '(min-width: 1440px)', + }, + }, + pictureFormatsOrder: ['widescreen', 'desktop', 'tablet', 'mobile'], }; diff --git a/projects/storefrontlib/shared/components/media/media.component.html b/projects/storefrontlib/shared/components/media/media.component.html index 50d303c2214..e4bcac48030 100644 --- a/projects/storefrontlib/shared/components/media/media.component.html +++ b/projects/storefrontlib/shared/components/media/media.component.html @@ -1,36 +1,85 @@ - - + + > + - - + + - - - + + + + + + + + + + + + + + + + diff --git a/projects/storefrontlib/shared/components/media/media.component.spec.ts b/projects/storefrontlib/shared/components/media/media.component.spec.ts index 9a5595add2f..adc3664f44c 100644 --- a/projects/storefrontlib/shared/components/media/media.component.spec.ts +++ b/projects/storefrontlib/shared/components/media/media.component.spec.ts @@ -1,13 +1,50 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import { + Directive, + Input, + Pipe, + PipeTransform, + TemplateRef, + ViewContainerRef, + inject, +} from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; + +import { InjectionToken } from '@angular/core'; +import { FeatureConfigService } from '@spartacus/core'; import { MediaComponent } from './media.component'; import { ImageLoadingStrategy, Media } from './media.model'; import { MediaService } from './media.service'; import { USE_LEGACY_MEDIA_COMPONENT } from './media.token'; +const IS_CONFIGURABLE_MEDIA_COMPONENT = new InjectionToken( + 'IS_CONFIGURABLE_MEDIA_COMPONENT' +); + const mediaUrl = 'mockProductImageUrl.jpg'; +@Directive({ + selector: '[cxFeature]', +}) +export class MockFeatureDirective { + protected templateRef = inject(TemplateRef); + protected viewContainer = inject(ViewContainerRef); + protected isConfigurableMediaComponent = inject( + IS_CONFIGURABLE_MEDIA_COMPONENT + ); + + @Input() set cxFeature(_feature: string) { + const featureIncludesExclamation = _feature.toString().includes('!'); + const shouldCreateView = + (featureIncludesExclamation && !this.isConfigurableMediaComponent) || + (!featureIncludesExclamation && this.isConfigurableMediaComponent); + + if (shouldCreateView) { + this.viewContainer.createEmbeddedView(this.templateRef); + } + } +} + @Pipe({ name: 'cxMediaSources', }) @@ -35,10 +72,16 @@ export class MockMediaSourcesPipe implements PipeTransform { } class MockMediaService { - srcset: string | undefined; + srcset: any; + useExtendedMediaComponentConfiguration: boolean; - constructor(srcset?: string) { + constructor( + srcset: string | null, + useExtendedMediaComponentConfiguration: boolean + ) { this.srcset = srcset; + this.useExtendedMediaComponentConfiguration = + useExtendedMediaComponentConfiguration; } getMedia(media: any): Media { @@ -48,6 +91,34 @@ class MockMediaService { alt: undefined, }; } + getMediaForPictureElement(media: any): Media | undefined { + return { + src: media ? media.product.url : undefined, + srcset: this.srcset, + alt: undefined, + sources: [ + { + srcset: 'test.url', + media: '', + width: undefined, + height: undefined, + }, + ], + }; + } + + getMediaBasedOnHTMLElementType( + elementType: 'img' | 'picture', + mediaContainer?: any + ) { + const shouldGetMediaForPictureElement = + this.useExtendedMediaComponentConfiguration && elementType !== 'img'; + + return shouldGetMediaForPictureElement + ? this.getMediaForPictureElement(mediaContainer) + : this.getMedia(mediaContainer); + } + getMissingImage() { return { src: 'missing.jpg', @@ -64,52 +135,129 @@ const mockImageContainer = { const mockMissingImageContainer = undefined; +class MockFeatureConfigService { + isEnabled() { + return true; + } +} + function configureTestingModule( mockMediaService: MockMediaService, - isLegacy: boolean = true + isLegacy: boolean = true, + isConfigurableMediaComponent = false ): void { TestBed.configureTestingModule({ - declarations: [MediaComponent, MockMediaSourcesPipe], + declarations: [MediaComponent, MockMediaSourcesPipe, MockFeatureDirective], providers: [ { provide: MediaService, useValue: mockMediaService }, { provide: USE_LEGACY_MEDIA_COMPONENT, useValue: isLegacy, }, + { + provide: IS_CONFIGURABLE_MEDIA_COMPONENT, + useValue: isConfigurableMediaComponent, + }, + { + provide: FeatureConfigService, + useClass: MockFeatureConfigService, + }, ], }).compileComponents(); } -function createComponent() { +function createComponent(elementType: 'picture' | 'img' = 'img') { const service = TestBed.inject(MediaService); const fixture = TestBed.createComponent(MediaComponent); const component = fixture.componentInstance; + const getMediaSpy = spyOn(service, 'getMedia').and.callThrough(); + const getMediaForPictureElementSpy = spyOn( + service, + 'getMediaForPictureElement' + ).and.callThrough(); + const getMediaBasedOnHTMLElementType = spyOn( + service, + 'getMediaBasedOnHTMLElementType' + ).and.callThrough(); component.container = mockImageContainer; + component.elementType = elementType; + component.ngOnChanges(); fixture.detectChanges(); - return { service, fixture, component }; + return { + service, + fixture, + component, + getMediaSpy, + getMediaForPictureElementSpy, + getMediaBasedOnHTMLElementType, + }; } describe('MediaComponent', () => { + describe('with enabled useExtendedMediaComponentConfiguration', () => { + it('should have picture element if elementType is `picture`', () => { + configureTestingModule(new MockMediaService('srcset', true), false, true); + const { fixture } = createComponent('picture'); + + const picture = fixture.debugElement.query(By.css('picture')); + + expect(picture).not.toBeNull(); + }); + + it('should not have picture element if elementType is `img`', () => { + configureTestingModule(new MockMediaService('srcset', true), false, true); + const { fixture } = createComponent(); + + const picture = fixture.debugElement.query(By.css('picture')); + + expect(picture).toBeNull(); + }); + + it('should call getMediaBasedOnHTMLElementType() method from service', () => { + configureTestingModule(new MockMediaService('srcset', true), false, true); + const { getMediaSpy } = createComponent(); + + expect(getMediaSpy).toHaveBeenCalled(); + }); + + it('should call getMediaForPictureElement() method from service if elementType is `picture`', () => { + configureTestingModule(new MockMediaService('srcset', true), false, true); + const { getMediaForPictureElementSpy, getMediaSpy } = + createComponent('picture'); + + expect(getMediaForPictureElementSpy).toHaveBeenCalled(); + expect(getMediaSpy).not.toHaveBeenCalled(); + }); + + it('should call getMedia() method from service if elementType is `img`', () => { + configureTestingModule(new MockMediaService('srcset', true), false, true); + const { getMediaForPictureElementSpy, getMediaSpy } = createComponent(); + + expect(getMediaForPictureElementSpy).not.toHaveBeenCalled(); + expect(getMediaSpy).toHaveBeenCalled(); + }); + }); + it('should create', () => { - configureTestingModule(new MockMediaService()); + configureTestingModule(new MockMediaService(null, false)); const { component } = createComponent(); expect(component).toBeTruthy(); }); it('should create media object with valid image url', () => { - configureTestingModule(new MockMediaService()); + configureTestingModule(new MockMediaService(null, false)); const { component } = createComponent(); expect(component?.media?.src).toEqual(mediaUrl); }); it('should update the img element with image url', () => { - configureTestingModule(new MockMediaService()); + configureTestingModule(new MockMediaService(null, false)); const { fixture } = createComponent(); expect( @@ -120,7 +268,7 @@ describe('MediaComponent', () => { }); it('should not contain the loading attribute for the image element', () => { - configureTestingModule(new MockMediaService()); + configureTestingModule(new MockMediaService(null, false)); const { fixture } = createComponent(); const el: HTMLElement = ( @@ -132,7 +280,7 @@ describe('MediaComponent', () => { }); it('should contain loading="lazy" for the image element', () => { - configureTestingModule(new MockMediaService()); + configureTestingModule(new MockMediaService(null, false)); const { service } = createComponent(); spyOnProperty(service, 'loadingStrategy').and.returnValue( @@ -150,7 +298,7 @@ describe('MediaComponent', () => { }); it('should contain is-loading classes while loading', () => { - configureTestingModule(new MockMediaService()); + configureTestingModule(new MockMediaService(null, false)); const { fixture } = createComponent(); expect( @@ -159,7 +307,7 @@ describe('MediaComponent', () => { }); it('should update classes when loaded', () => { - configureTestingModule(new MockMediaService()); + configureTestingModule(new MockMediaService(null, false)); const { fixture } = createComponent(); const load = new UIEvent('load'); @@ -176,15 +324,15 @@ describe('MediaComponent', () => { }); it('should have is-missing class when there is no image', () => { - configureTestingModule(new MockMediaService()); - const { service, fixture, component } = createComponent(); + configureTestingModule(new MockMediaService(null, false)); + const { fixture, component, getMediaSpy } = createComponent(); component.container = mockImageContainer; component.ngOnChanges(); fixture.detectChanges(); - spyOn(service, 'getMedia').and.returnValue(null); + getMediaSpy.and.returnValue(null); component.container = mockMissingImageContainer; component.ngOnChanges(); @@ -196,7 +344,7 @@ describe('MediaComponent', () => { }); it('should not have picture element if there is no srcset in media', () => { - configureTestingModule(new MockMediaService()); + configureTestingModule(new MockMediaService(null, false)); const { fixture } = createComponent(); const picture = fixture.debugElement.query(By.css('picture')); @@ -205,7 +353,7 @@ describe('MediaComponent', () => { }); it('should have picture element if there is srcset in media', () => { - configureTestingModule(new MockMediaService('srcset'), false); + configureTestingModule(new MockMediaService('srcset', false), false); const { fixture } = createComponent(); const picture = fixture.debugElement.query(By.css('picture')); @@ -214,7 +362,7 @@ describe('MediaComponent', () => { }); it('should not have picture element if there is srcset in media but isLegacy mode', () => { - configureTestingModule(new MockMediaService('srcset')); + configureTestingModule(new MockMediaService('srcset', false)); const { fixture } = createComponent(); const picture = fixture.debugElement.query(By.css('picture')); diff --git a/projects/storefrontlib/shared/components/media/media.component.ts b/projects/storefrontlib/shared/components/media/media.component.ts index 1df9612fc95..7f549a3509d 100644 --- a/projects/storefrontlib/shared/components/media/media.component.ts +++ b/projects/storefrontlib/shared/components/media/media.component.ts @@ -61,6 +61,41 @@ export class MediaComponent implements OnChanges { */ @Input() loading: ImageLoadingStrategy | null = this.loadingStrategy; + /** + * Works only when `useExtendedMediaComponentConfiguration` toggle is true + * + * @default img + */ + @Input() elementType: 'img' | 'picture' = 'img'; + + /** + * The intrinsic width of the image, in pixels + * + * Works only when `useExtendedMediaComponentConfiguration` toggle is true + */ + @Input() width: number; + + /** + * The intrinsic height of the image, in pixels + * + * Works only when `useExtendedMediaComponentConfiguration` toggle is true + */ + @Input() height: number; + + /** + * Specifies the sizes attribute for responsive images. + * + * The `sizes` attribute describes the layout width of the image for various viewport sizes. + * It helps the browser determine which image to download from the `srcset` attribute. + * + * - The sizes attribute is defined using media queries. + * - It allows specifying different sizes for various screen widths or other conditions (e.g., device orientation). + * - The browser uses the value to pick the most appropriate image source from the `srcset`. + * + * Works only when `useExtendedMediaComponentConfiguration` toggle is true + */ + @Input() sizes: string; + /** * Once the media is loaded, we emit an event. */ @@ -95,6 +130,13 @@ export class MediaComponent implements OnChanges { protected trackByMedia: TrackByFunction = (_, item) => item.media; + /** + * @deprecated since 2211.30. It will be eventually removed in the future + * + * To use `img` HTML element instead of `picture` + * use `useExtendedMediaComponentConfiguration` feature flag + * and pass `[elementType]="'img'"` input to the component + */ protected isLegacy = inject(USE_LEGACY_MEDIA_COMPONENT, { optional: true }) || (inject(Config) as any)['useLegacyMediaComponent'] || @@ -110,12 +152,14 @@ export class MediaComponent implements OnChanges { * Creates the `Media` object */ protected create(): void { - this.media = this.mediaService.getMedia( + this.media = this.mediaService.getMediaBasedOnHTMLElementType( + this.elementType, this.container instanceof Array ? this.container[0] : this.container, this.format, this.alt, this.role ); + if (!this.media?.src) { this.handleMissing(); } diff --git a/projects/storefrontlib/shared/components/media/media.config.ts b/projects/storefrontlib/shared/components/media/media.config.ts index db5a428d457..6b899b8264e 100644 --- a/projects/storefrontlib/shared/components/media/media.config.ts +++ b/projects/storefrontlib/shared/components/media/media.config.ts @@ -6,7 +6,11 @@ import { Injectable } from '@angular/core'; import { Config } from '@spartacus/core'; -import { ImageLoadingStrategy, MediaFormatSize } from './media.model'; +import { + ImageLoadingStrategy, + MediaFormatSize, + PictureElementQueries, +} from './media.model'; /** * Provides configuration specific to Media, such as images. This is used to optimize @@ -29,6 +33,33 @@ export abstract class MediaConfig { [format: string]: MediaFormatSize; }; + /** + * Picture element configuration holds the media queries assigned to + * a format. + * The order of formats matters. + * elements in will be sorted based on this order. + * This is necessary because the browser evaluates each + * element in order and uses the first one that matches. + */ + pictureElementFormats?: { + [format: string]: { + mediaQueries?: PictureElementQueries; + width?: number; + height?: number; + }; + }; + + /** + * Used to specify the order of formats. + * elements in will be sorted based on this order. + * This is necessary because the browser evaluates each + * element in order and uses the first one that matches. + * + * @example + * ['mobile', 'tablet', 'desktop'] + */ + pictureFormatsOrder?: string[]; + /** * Indicates how the browser should load the image. There's only one * global strategy for all media cross media in Spartacus. @@ -42,6 +73,7 @@ export abstract class MediaConfig { imageLoadingStrategy?: ImageLoadingStrategy; /** + * @deprecated since 2211.30. It will be eventually removed in the future * As of v7.0, Spartacus started using the element by default when a srcset is available. * * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture for more diff --git a/projects/storefrontlib/shared/components/media/media.model.ts b/projects/storefrontlib/shared/components/media/media.model.ts index 5bfbef423dd..a4a0d04402f 100644 --- a/projects/storefrontlib/shared/components/media/media.model.ts +++ b/projects/storefrontlib/shared/components/media/media.model.ts @@ -29,6 +29,21 @@ export interface Media { * readers or magnifiers */ role?: string; + + /** + * The sources holds a list of source element for picture html element + */ + sources?: PictureHTMLElementSources[]; + + /** + * Specifies the intrinsic width of the image in pixels. Allowed if the parent of `` is a `` + */ + width?: number; + + /** + * Specifies the intrinsic height of the image in pixels. Allowed if the parent of `` is a `` + */ + height?: number; } /** @@ -51,6 +66,22 @@ export interface MediaFormatSize { width?: number; } +/** + * Specifies media queries that can be used to generate information for the + * browser to resolve the right media for the right layout or device. + * + * You can check available queries in official docs + * https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries + */ +export type PictureElementQueries = string; + +export interface PictureHTMLElementSources { + srcset: string; + media: string; + width: number | undefined; + height: number | undefined; +} + /** * Indicates how the browser should load the image. * diff --git a/projects/storefrontlib/shared/components/media/media.module.ts b/projects/storefrontlib/shared/components/media/media.module.ts index e2fa182bb0d..6f8156a6493 100644 --- a/projects/storefrontlib/shared/components/media/media.module.ts +++ b/projects/storefrontlib/shared/components/media/media.module.ts @@ -6,11 +6,12 @@ import { CommonModule } from '@angular/common'; import { ModuleWithProviders, NgModule } from '@angular/core'; +import { FeaturesConfigModule } from '@spartacus/core'; import { MediaSourcesPipe } from './media-sources.pipe'; import { MediaComponent } from './media.component'; @NgModule({ - imports: [CommonModule], + imports: [CommonModule, FeaturesConfigModule], declarations: [MediaComponent, MediaSourcesPipe], exports: [MediaComponent], }) diff --git a/projects/storefrontlib/shared/components/media/media.service.spec.ts b/projects/storefrontlib/shared/components/media/media.service.spec.ts index b45c825ee90..8b9dc5d2b14 100644 --- a/projects/storefrontlib/shared/components/media/media.service.spec.ts +++ b/projects/storefrontlib/shared/components/media/media.service.spec.ts @@ -24,6 +24,22 @@ const MockStorefrontConfig: Config = { width: 1, }, }, + pictureElementFormats: { + format400: { + mediaQueries: + '(max-width: 768px) and (-webkit-min-device-pixel-ratio: 3)', + }, + format200: { + mediaQueries: '(min-width: 768px) and (max-width: 1024px)', + }, + format600: { + mediaQueries: '(min-width: 1025px) and (max-width: 1439px)', + }, + format1: { + mediaQueries: '(min-width: 1440px)', + }, + }, + pictureFormatsOrder: ['format1', 'format200', 'format400', 'format600'], }; const mockUnknownMediaContainer = { @@ -91,13 +107,7 @@ describe('MediaService', () => { let mediaService: MediaService; beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - MediaService, - { provide: Config, useValue: MockStorefrontConfig }, - { provide: LayoutConfig, useValue: {} }, - ], - }); + configureTestingModule(MockStorefrontConfig); mediaService = TestBed.inject(MediaService); }); @@ -105,11 +115,10 @@ describe('MediaService', () => { expect(mediaService).toBeTruthy(); }); - describe('getMedia', () => { + describe('get media methods', () => { it('should return undefined if no mediaContainer', () => { - const result = mediaService.getMedia(); - - expect(result).toBeUndefined(); + expect(mediaService.getMedia()).toBeUndefined(); + expect(mediaService.getMediaForPictureElement()).toBeUndefined(); }); describe('without format', () => { @@ -117,26 +126,41 @@ describe('MediaService', () => { expect(mediaService.getMedia(mockBestFormatMediaContainer)?.src).toBe( 'base:format-600.url' ); + expect( + mediaService.getMediaForPictureElement(mockBestFormatMediaContainer) + ?.src + ).toBe('base:format-600.url'); }); it('should return the first media if there is no configured format', () => { expect(mediaService.getMedia(mockUnknownMediaContainer)?.src).toBe( 'base:random1.url' ); + expect( + mediaService.getMediaForPictureElement(mockUnknownMediaContainer) + ?.src + ).toBe('base:random1.url'); }); it('should return media', () => { expect(mediaService.getMedia(mockMedia)?.src).toBe('base:media.url'); + expect(mediaService.getMediaForPictureElement(mockMedia)?.src).toBe( + 'base:media.url' + ); }); it('should not return src if there are no medias in the container', () => { expect(mediaService.getMedia({})?.src).toBeFalsy(); + expect(mediaService.getMediaForPictureElement({})?.src).toBeFalsy(); }); it('should not return src if there is no media url', () => { expect( mediaService.getMedia({ altText: 'whatever' })?.src ).toBeFalsy(); + expect( + mediaService.getMediaForPictureElement({ altText: 'whatever' })?.src + ).toBeFalsy(); }); }); @@ -145,6 +169,9 @@ describe('MediaService', () => { expect(mediaService.getMedia(mockMedia, 'any')?.src).toBe( 'base:media.url' ); + expect( + mediaService.getMediaForPictureElement(mockMedia, 'any')?.src + ).toBe('base:media.url'); }); it('should return media for format', () => { @@ -152,24 +179,48 @@ describe('MediaService', () => { mediaService.getMedia(mockBestFormatMediaContainer, 'format400') ?.src ).toBe('base:format-400.url'); + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'format400' + )?.src + ).toBe('base:format-400.url'); }); it('should return best media for unknown format', () => { expect( mediaService.getMedia(mockBestFormatMediaContainer, 'unknown')?.src ).toBe('base:format-600.url'); + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'unknown' + )?.src + ).toBe('base:format-600.url'); }); it('should return random media for unknown format and unknown media formats', () => { expect( mediaService.getMedia(mockUnknownMediaContainer, 'unknown')?.src ).toBe('base:random1.url'); + expect( + mediaService.getMediaForPictureElement( + mockUnknownMediaContainer, + 'unknown' + )?.src + ).toBe('base:random1.url'); }); it('should not return src if the media container does not contain a url', () => { expect( mediaService.getMedia({ cont: { format: 'xyz' } }, 'xyz')?.src ).toBeFalsy(); + expect( + mediaService.getMediaForPictureElement( + { cont: { format: 'xyz' } }, + 'xyz' + )?.src + ).toBeFalsy(); }); }); @@ -178,12 +229,19 @@ describe('MediaService', () => { expect(mediaService.getMedia(mockMedia)?.alt).toBe( 'alt text for media' ); + expect(mediaService.getMediaForPictureElement(mockMedia)?.alt).toBe( + 'alt text for media' + ); }); it('should return alt text for best media', () => { expect(mediaService.getMedia(mockBestFormatMediaContainer)?.alt).toBe( 'alt text for format-600' ); + expect( + mediaService.getMediaForPictureElement(mockBestFormatMediaContainer) + ?.alt + ).toBe('alt text for format-600'); }); it('should return alt text for specific format', () => { @@ -191,18 +249,36 @@ describe('MediaService', () => { mediaService.getMedia(mockBestFormatMediaContainer, 'format400') ?.alt ).toBe('alt text for format-400'); + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'format400' + )?.alt + ).toBe('alt text for format-400'); }); it('should return alt text for best format', () => { expect( mediaService.getMedia(mockBestFormatMediaContainer, 'unknown')?.alt ).toBe('alt text for format-600'); + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'unknown' + )?.alt + ).toBe('alt text for format-600'); }); it('should return alt text for random format', () => { expect( mediaService.getMedia(mockUnknownMediaContainer, 'unknown')?.alt ).toBe('alt text for unknown-1'); + expect( + mediaService.getMediaForPictureElement( + mockUnknownMediaContainer, + 'unknown' + )?.alt + ).toBe('alt text for unknown-1'); }); it('should return given alt text', () => { @@ -213,18 +289,32 @@ describe('MediaService', () => { 'custom alt' )?.alt ).toEqual('custom alt'); + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'format400', + 'custom alt' + )?.alt + ).toEqual('custom alt'); }); }); describe('role', () => { it('should return role for media', () => { expect(mediaService.getMedia(mockMedia)?.role).toBe('presentation'); + expect(mediaService.getMediaForPictureElement(mockMedia)?.role).toBe( + 'presentation' + ); }); it('should return role for best media', () => { expect( mediaService.getMedia(mockBestFormatMediaContainer)?.role ).toBe('presentation'); + expect( + mediaService.getMediaForPictureElement(mockBestFormatMediaContainer) + ?.role + ).toBe('presentation'); }); it('should return role for specific format', () => { @@ -232,18 +322,36 @@ describe('MediaService', () => { mediaService.getMedia(mockBestFormatMediaContainer, 'format400') ?.role ).toBe('presentation'); + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'format400' + )?.role + ).toBe('presentation'); }); it('should return role for best format', () => { expect( mediaService.getMedia(mockBestFormatMediaContainer, 'unknown')?.role ).toBe('presentation'); + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'unknown' + )?.role + ).toBe('presentation'); }); it('should return role for random format', () => { expect( mediaService.getMedia(mockUnknownMediaContainer, 'unknown')?.role ).toBe('presentation'); + expect( + mediaService.getMediaForPictureElement( + mockUnknownMediaContainer, + 'unknown' + )?.role + ).toBe('presentation'); }); it('should return given role', () => { @@ -255,6 +363,14 @@ describe('MediaService', () => { 'custom role' )?.role ).toEqual('custom role'); + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'format400', + 'custom alt', + 'custom role' + )?.role + ).toEqual('custom role'); }); }); @@ -297,23 +413,134 @@ describe('MediaService', () => { }); }); + describe('sources', () => { + it('should return undefined if not media', () => { + const result = mediaService['resolveSources']( + null as unknown as MediaContainer + ); + + expect(result).toBeUndefined(); + }); + + it('should return all images in sources', () => { + const expectedResult = [ + { + srcset: 'base:format-1.url', + media: '(min-width: 1440px)', + width: undefined, + height: undefined, + }, + { + srcset: 'base:format-400.url', + media: '(max-width: 768px) and (-webkit-min-device-pixel-ratio: 3)', + width: undefined, + height: undefined, + }, + { + srcset: 'base:format-600.url', + media: '(min-width: 1025px) and (max-width: 1439px)', + width: undefined, + height: undefined, + }, + ]; + + const result = mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer + )?.sources; + + expect(result).toEqual(expectedResult); + }); + + it('should return only relevant images in sources for the given max format', () => { + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'format400' + )?.sources + ).toEqual([ + { + srcset: 'base:format-1.url', + media: '(min-width: 1440px)', + width: undefined, + height: undefined, + }, + { + srcset: 'base:format-400.url', + media: '(max-width: 768px) and (-webkit-min-device-pixel-ratio: 3)', + width: undefined, + height: undefined, + }, + ]); + }); + + it('should return all formats for unknown format', () => { + const expectedResult = [ + { + srcset: 'base:format-1.url', + media: '(min-width: 1440px)', + width: undefined, + height: undefined, + }, + { + srcset: 'base:format-400.url', + media: '(max-width: 768px) and (-webkit-min-device-pixel-ratio: 3)', + width: undefined, + height: undefined, + }, + { + srcset: 'base:format-600.url', + media: '(min-width: 1025px) and (max-width: 1439px)', + width: undefined, + height: undefined, + }, + ]; + + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'unknown' + )?.sources + ).toEqual(expectedResult); + }); + + it('should return empty array for media without any formats', () => { + expect( + mediaService.getMediaForPictureElement(mockMedia)?.sources?.length + ).toBe(0); + }); + }); + describe('absolute URL', () => { it('should avoid baseUrl if absolute image is provided', () => { expect(mediaService.getMedia(mockUrlContainer, 'absolute')?.src).toBe( 'http://absolute.jpg' ); + expect( + mediaService.getMediaForPictureElement(mockUrlContainer, 'absolute') + ?.src + ).toBe('http://absolute.jpg'); }); it('should threat image url start with double slash as absolute URL', () => { expect( mediaService.getMedia(mockUrlContainer, 'doubleSlash')?.src ).toBe('//absolute.jpg'); + expect( + mediaService.getMediaForPictureElement( + mockUrlContainer, + 'doubleSlash' + )?.src + ).toBe('//absolute.jpg'); }); it('should add OCC baseUrl for relative image', () => { expect(mediaService.getMedia(mockUrlContainer, 'relative')?.src).toBe( 'base:relative.jpg' ); + expect( + mediaService.getMediaForPictureElement(mockUrlContainer, 'relative') + ?.src + ).toBe('base:relative.jpg'); }); }); @@ -330,19 +557,10 @@ describe('MediaService', () => { let mediaService: MediaService; beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - MediaService, - { - provide: Config, - useValue: { - ...MockStorefrontConfig, - mediaLoadingStrategy: ImageLoadingStrategy.LAZY, - }, - }, - { provide: LayoutConfig, useValue: {} }, - ], - }); + configureTestingModule({ + ...MockStorefrontConfig, + mediaLoadingStrategy: ImageLoadingStrategy.LAZY, + } as any); mediaService = TestBed.inject(MediaService); }); describe('loadingStrategy', () => { @@ -354,20 +572,115 @@ describe('MediaService', () => { }); }); + describe('sources order', () => { + it('should be sorted based on provided config', () => { + const config = { + ...MockStorefrontConfig, + pictureFormatsOrder: ['format600', 'format1', 'format400'], + }; + configureTestingModule(config); + const mediaService = TestBed.inject(MediaService); + + const result = mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer + )?.sources; + + if (result && result[0]?.srcset) { + expect(result[0].srcset).toBe('base:format-600.url'); + expect(result[1].srcset).toBe('base:format-1.url'); + expect(result[2].srcset).toBe('base:format-400.url'); + } else { + fail('Sources is undefined'); + } + }); + + it('should be sorted in natural order if the order was not provided in config', () => { + const config = { + ...MockStorefrontConfig, + pictureFormatsOrder: [], + }; + configureTestingModule(config); + const mediaService = TestBed.inject(MediaService); + + const result = mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer + )?.sources; + + if (result && result[0]?.srcset) { + expect(result[0].srcset).toBe('base:format-400.url'); + expect(result[1].srcset).toBe('base:format-600.url'); + expect(result[2].srcset).toBe('base:format-1.url'); + } else { + fail('Sources is undefined'); + } + }); + }); + + describe('width and height attributes for picture sources', () => { + it('should return all images with proper width and height properties based on values from config', () => { + const config = { + ...MockStorefrontConfig, + pictureElementFormats: { + format400: { + mediaQueries: + '(max-width: 768px) and (-webkit-min-device-pixel-ratio: 3)', + width: 200, + height: 300, + }, + format200: { + mediaQueries: '(min-width: 768px) and (max-width: 1024px)', + width: 700, + height: 1000, + }, + format600: { + mediaQueries: '(min-width: 1025px) and (max-width: 1439px)', + width: 1000, + height: 800, + }, + format1: { + mediaQueries: '(min-width: 1440px)', + width: 1440, + height: 1000, + }, + }, + }; + configureTestingModule(config); + const service = TestBed.inject(MediaService); + + const expectedResult = [ + { + srcset: 'base:format-1.url', + media: '(min-width: 1440px)', + width: 1440, + height: 1000, + }, + { + srcset: 'base:format-400.url', + media: '(max-width: 768px) and (-webkit-min-device-pixel-ratio: 3)', + width: 200, + height: 300, + }, + { + srcset: 'base:format-600.url', + media: '(min-width: 1025px) and (max-width: 1439px)', + width: 1000, + height: 800, + }, + ]; + + const result = service.getMediaForPictureElement( + mockBestFormatMediaContainer + )?.sources; + + expect(result).toEqual(expectedResult); + }); + }); + describe('without media config', () => { let mediaService: MediaService; beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - MediaService, - { - provide: Config, - useValue: {}, - }, - { provide: LayoutConfig, useValue: {} }, - ], - }); + configureTestingModule({}); mediaService = TestBed.inject(MediaService); }); @@ -375,30 +688,71 @@ describe('MediaService', () => { expect(mediaService.getMedia(mockBestFormatMediaContainer)?.src).toBe( 'format-1.url' ); + expect( + mediaService.getMediaForPictureElement(mockBestFormatMediaContainer) + ?.src + ).toBe('format-1.url'); }); it('should return first available media for unknown format', () => { expect( mediaService.getMedia(mockBestFormatMediaContainer, 'unknown')?.src ).toBe('format-1.url'); + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'unknown' + )?.src + ).toBe('format-1.url'); }); it('should not return srcset for unknown format', () => { expect( mediaService.getMedia(mockBestFormatMediaContainer, 'unknown')?.srcset ).toBeFalsy(); + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'unknown' + )?.srcset + ).toBeFalsy(); }); it('should return specific media for given format', () => { expect( mediaService.getMedia(mockBestFormatMediaContainer, 'format600')?.src ).toBe('format-600.url'); + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'format600' + )?.src + ).toBe('format-600.url'); }); it('should not return srcset for given format', () => { expect( mediaService.getMedia(mockBestFormatMediaContainer, 'format600')?.srcset ).toBeFalsy(); + expect( + mediaService.getMediaForPictureElement( + mockBestFormatMediaContainer, + 'format600' + )?.srcset + ).toBeFalsy(); }); }); }); + +function configureTestingModule(config: Config): void { + TestBed.configureTestingModule({ + providers: [ + MediaService, + { + provide: Config, + useValue: config, + }, + { provide: LayoutConfig, useValue: {} }, + ], + }); +} diff --git a/projects/storefrontlib/shared/components/media/media.service.ts b/projects/storefrontlib/shared/components/media/media.service.ts index ac8412fe178..4d2a258d71b 100644 --- a/projects/storefrontlib/shared/components/media/media.service.ts +++ b/projects/storefrontlib/shared/components/media/media.service.ts @@ -4,14 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Injectable } from '@angular/core'; -import { Config, Image } from '@spartacus/core'; +import { Injectable, inject } from '@angular/core'; +import { Config, FeatureConfigService, Image } from '@spartacus/core'; import { MediaConfig } from './media.config'; import { ImageLoadingStrategy, Media, MediaContainer, MediaFormatSize, + PictureHTMLElementSources, } from './media.model'; /** @@ -35,13 +36,35 @@ export class MediaService { * size is sorted on top. */ private _sortedFormats: { code: string; size: MediaFormatSize }[]; + private _sortedPictureFormats: { code: string; mediaQuery: string }[]; private _reversedFormats: { code: string; size: MediaFormatSize }[]; + private readonly featureConfigService = inject(FeatureConfigService); + constructor(protected config: Config) {} + getMediaBasedOnHTMLElementType( + elementType: 'img' | 'picture', + mediaContainer?: MediaContainer | Image, + format?: string, + alt?: string, + role?: string + ): Media | undefined { + const shouldGetMediaForPictureElement = + this.featureConfigService.isEnabled( + 'useExtendedMediaComponentConfiguration' + ) && elementType !== 'img'; + + return shouldGetMediaForPictureElement + ? this.getMediaForPictureElement(mediaContainer, format, alt, role) + : this.getMedia(mediaContainer, format, alt, role); + } + /** * Returns a `Media` object with the main media (`src`) and various media (`src`) * for specific formats. + * + * This method is used for creating `Media` object that is used in `img` HTML element */ getMedia( mediaContainer?: MediaContainer | Image, @@ -53,6 +76,55 @@ export class MediaService { return; } + const commonMediaProperties = this.getCommonMediaObject( + mediaContainer, + format, + alt, + role + ); + + return { + ...commonMediaProperties, + srcset: this.resolveSrcSet(mediaContainer, format), + }; + } + + /** + * Returns a `Media` object with the main media (`src`) and various sources + * for specific formats for HTML `` element. + */ + getMediaForPictureElement( + mediaContainer?: MediaContainer | Image, + format?: string, + alt?: string, + role?: string + ): Media | undefined { + if (!mediaContainer) { + return; + } + + const commonMediaProperties = this.getCommonMediaObject( + mediaContainer, + format, + alt, + role + ); + + return { + ...commonMediaProperties, + sources: this.resolveSources(mediaContainer, format), + }; + } + + /** + * Generates attributes common for `` ang ``. + */ + protected getCommonMediaObject( + mediaContainer: MediaContainer | Image, + format?: string, + alt?: string, + role?: string + ): Media { const mainMedia: Image = mediaContainer.url ? mediaContainer : this.resolveMedia(mediaContainer as MediaContainer, format); @@ -61,7 +133,6 @@ export class MediaService { src: this.resolveAbsoluteUrl(mainMedia?.url ?? ''), alt: alt ?? mainMedia?.altText, role: role ?? mainMedia?.role, - srcset: this.resolveSrcSet(mediaContainer, format), }; } @@ -84,6 +155,7 @@ export class MediaService { */ protected get sortedFormats(): { code: string; size: MediaFormatSize }[] { const mediaFormats = this.config?.mediaFormats; + if (!this._sortedFormats && mediaFormats) { this._sortedFormats = Object.keys(mediaFormats) .map((key) => ({ @@ -94,9 +166,50 @@ export class MediaService { a.size.width && b.size.width && a.size.width > b.size.width ? 1 : -1 ); } + return this._sortedFormats ?? []; } + /** + * Creates the media formats in a logical sorted order. The map contains the + * format key and the format media query information. We do this only once for performance + * benefits. + */ + protected get sortedPictureFormats(): { + code: string; + mediaQuery: string; + }[] { + const pictureElementMediaFormats = this.config?.pictureElementFormats; + const pictureFormatsOrder = this.config?.pictureFormatsOrder; + + if (!pictureElementMediaFormats) { + return []; + } + + if (!this._sortedPictureFormats && pictureElementMediaFormats) { + this._sortedPictureFormats = Object.keys(pictureElementMediaFormats).map( + (key) => ({ + code: key, + mediaQuery: pictureElementMediaFormats[key]?.mediaQueries || '', + }) + ); + + if (pictureFormatsOrder) { + this._sortedPictureFormats.sort((a, b) => { + const orderA = pictureFormatsOrder.indexOf(a.code); + const orderB = pictureFormatsOrder.indexOf(b.code); + + return ( + (orderA !== -1 ? orderA : Infinity) - + (orderB !== -1 ? orderB : Infinity) + ); + }); + } + } + + return this._sortedPictureFormats ?? []; + } + /** * Creates the media formats in a reversed sorted order. */ @@ -157,12 +270,7 @@ export class MediaService { return undefined; } - // Only create srcset images that are smaller than the given `maxFormat` (if any) - let formats = this.sortedFormats; - const max: number = formats.findIndex((f) => f.code === maxFormat); - if (max > -1) { - formats = formats.slice(0, max + 1); - } + const formats = this.getFormatsUpToMaxFormat(this.sortedFormats, maxFormat); const srcset = formats.reduce((set, format) => { const image = (media as MediaContainer)[format.code]; @@ -180,6 +288,77 @@ export class MediaService { return srcset === '' ? undefined : srcset; } + /** + * Resolves the sources for a picture element based on the provided media container and maximum format. + * + * This method generates an array of picture element attributes (`srcset` and `media`) by filtering + * the sorted picture formats up to the specified maximum format. It then maps the corresponding + * media sources from the provided media container. + * + * The method will return an array of picture element attributes suitable for use + * in a `` element, or `undefined` if no media is provided. + */ + protected resolveSources( + media: MediaContainer | Image, + maxFormat?: string + ): PictureHTMLElementSources[] | undefined { + if (!media) { + return undefined; + } + + const pictureFormats = this.getFormatsUpToMaxFormat( + this.sortedPictureFormats, + maxFormat + ); + const pictureElementMediaFormats = this.config?.pictureElementFormats; + + return pictureFormats.reduce< + { + srcset: string; + media: string; + width: number | undefined; + height: number | undefined; + }[] + >((sources, format) => { + const image = (media as MediaContainer)[format.code]; + + if (image?.url) { + sources.push({ + srcset: this.resolveAbsoluteUrl(image.url), + media: format.mediaQuery, + width: pictureElementMediaFormats?.[format?.code]?.width, + height: pictureElementMediaFormats?.[format?.code]?.height, + }); + } + return sources; + }, []); + } + + /** + * Retrieves a list of formats up to and including the specified max format. + * + * @template T - A type that extends an object containing a `code` property of type `string`. + * @param {T[]} formats - An array of format objects, each containing at least a `code` property. + * @param {string} [maxFormat] - The maximum format code to include in the returned list. + * @returns {T[]} An array of formats up to and including the specified max format. If `maxFormat` is not found, returns the entire list. + * + * This method filters the provided list of formats to include only those up to and including + * the specified max format. If the `maxFormat` is not found, the entire list of formats is returned. + */ + protected getFormatsUpToMaxFormat( + formats: T[], + maxFormat?: string + ): T[] { + let pictureFormats = formats; + const maxIndex = pictureFormats.findIndex((f) => f.code === maxFormat); + + if (maxIndex > -1) { + pictureFormats = pictureFormats.slice(0, maxIndex + 1); + } + + return pictureFormats; + } + /** * Resolves the absolute URL for the given url. In most cases, this URL represents * the relative URL on the backend. In that case, we prefix the url with the baseUrl. diff --git a/projects/storefrontlib/shared/components/media/media.token.ts b/projects/storefrontlib/shared/components/media/media.token.ts index 89a760606a6..0673c7cacc8 100644 --- a/projects/storefrontlib/shared/components/media/media.token.ts +++ b/projects/storefrontlib/shared/components/media/media.token.ts @@ -6,6 +6,9 @@ import { InjectionToken } from '@angular/core'; +/** + * @deprecated since 2211.30. It will be eventually removed in the future + */ export const USE_LEGACY_MEDIA_COMPONENT = new InjectionToken( 'USE_LEGACY_MEDIA_COMPONENT' );