diff --git a/aio/content/guide/image-directive.md b/aio/content/guide/image-directive.md index c074279e1fe8d..e20864e5702a9 100644 --- a/aio/content/guide/image-directive.md +++ b/aio/content/guide/image-directive.md @@ -81,6 +81,18 @@ providers: [ +### Using `fill` mode + +In cases where you want to have an image fill a containing element, you can use the `fill` attribute. This is often useful when you want to achieve a "background image" behavior, or when you don't know the exact width and height of your image. + +When you add the `fill` attribute to your image, you do not need and should not include a `width` and `height`, as in this example: + + + +<img ngSrc="cat.jpg" fill> + + + ### Adjusting image styling Depending on the image's styling, adding `width` and `height` attributes may cause the image to render differently. `NgOptimizedImage` warns you if your image styling renders the image at a distorted aspect ratio. diff --git a/goldens/public-api/common/index.md b/goldens/public-api/common/index.md index ab18be6cb5f87..904604de8757d 100644 --- a/goldens/public-api/common/index.md +++ b/goldens/public-api/common/index.md @@ -549,6 +549,9 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { set disableOptimizedSrcset(value: string | boolean | undefined); // (undocumented) get disableOptimizedSrcset(): boolean; + set fill(value: string | boolean | undefined); + // (undocumented) + get fill(): boolean; set height(value: string | number | undefined); // (undocumented) get height(): number | undefined; @@ -571,7 +574,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { // (undocumented) get width(): number | undefined; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } diff --git a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts index 9d24eea2ffb3c..d645a97e9fd7e 100644 --- a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts +++ b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts @@ -202,6 +202,12 @@ export const IMAGE_CONFIG = new InjectionToken( @Directive({ standalone: true, selector: 'img[ngSrc],img[rawSrc]', + host: { + '[style.position]': 'fill ? "absolute" : null', + '[style.width]': 'fill ? "100%" : null', + '[style.height]': 'fill ? "100%" : null', + '[style.inset]': 'fill ? "0px" : null' + } }) export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { private imageLoader = inject(IMAGE_LOADER); @@ -330,6 +336,19 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { } private _disableOptimizedSrcset = false; + /** + * Sets the image to "fill mode," which eliminates the height/width requirement and adds + * styles such that the image fills its containing element. + */ + @Input() + set fill(value: string|boolean|undefined) { + this._fill = inputToBoolean(value); + } + get fill(): boolean { + return this._fill; + } + private _fill = false; + /** * Value of the `src` attribute if set on the host `` element. * This input is exclusively read to assert that `src` is not set in conflict @@ -356,7 +375,11 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { } assertNotBase64Image(this); assertNotBlobUrl(this); - assertNonEmptyWidthAndHeight(this); + if (this.fill) { + assertEmptyWidthAndHeight(this); + } else { + assertNonEmptyWidthAndHeight(this); + } assertValidLoadingInput(this); assertNoImageDistortion(this, this.imgElement, this.renderer); if (!this.ngSrcset) { @@ -383,8 +406,14 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { private setHostAttributes() { // Must set width/height explicitly in case they are bound (in which case they will // only be reflected and not found by the browser) - this.setHostAttribute('width', this.width!.toString()); - this.setHostAttribute('height', this.height!.toString()); + if (this.fill) { + if (!this.sizes) { + this.sizes = '100vw'; + } + } else { + this.setHostAttribute('width', this.width!.toString()); + this.setHostAttribute('height', this.height!.toString()); + } this.setHostAttribute('loading', this.getLoadingBehavior()); this.setHostAttribute('fetchpriority', this.getFetchPriority()); @@ -805,6 +834,22 @@ function assertNonEmptyWidthAndHeight(dir: NgOptimizedImage) { } } +/** + * Verifies that width and height are not set. Used in fill mode, where those attributes don't make + * sense. + */ +function assertEmptyWidthAndHeight(dir: NgOptimizedImage) { + if (dir.width || dir.height) { + throw new RuntimeError( + RuntimeErrorCode.INVALID_INPUT, + `${ + imgDirectiveDetails( + dir.ngSrc)} the attributes \`height\` and/or \`width\` are present ` + + `along with the \`fill\` attribute. Because \`fill\` mode causes an image to fill its containing ` + + `element, the size attributes have no effect and should be removed.`); + } +} + /** * Verifies that the `loading` attribute is set to a valid input & * is not used on priority images. diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts index 8483bf6d5a3d8..8870960cee715 100644 --- a/packages/common/test/directives/ng_optimized_image_spec.ts +++ b/packages/common/test/directives/ng_optimized_image_spec.ts @@ -794,6 +794,123 @@ describe('Image directive', () => { }); }); + describe('fill mode', () => { + it('should allow unsized images in fill mode', () => { + setupTestingModule(); + + const template = ''; + expect(() => { + const fixture = createTestComponent(template); + fixture.detectChanges(); + }).not.toThrow(); + }); + it('should throw if width is provided for fill mode image', () => { + setupTestingModule(); + + const template = ''; + expect(() => { + const fixture = createTestComponent(template); + fixture.detectChanges(); + }) + .toThrowError( + 'NG02952: The NgOptimizedImage directive (activated on an element with the ' + + '`ngSrc="path/img.png"`) has detected that the attributes `height` and/or `width` ' + + 'are present along with the `fill` attribute. Because `fill` mode causes an image ' + + 'to fill its containing element, the size attributes have no effect and should be removed.'); + }); + it('should throw if height is provided for fill mode image', () => { + setupTestingModule(); + + const template = ''; + expect(() => { + const fixture = createTestComponent(template); + fixture.detectChanges(); + }) + .toThrowError( + 'NG02952: The NgOptimizedImage directive (activated on an element with the ' + + '`ngSrc="path/img.png"`) has detected that the attributes `height` and/or `width` ' + + 'are present along with the `fill` attribute. Because `fill` mode causes an image ' + + 'to fill its containing element, the size attributes have no effect and should be removed.'); + }); + it('should apply appropriate styles in fill mode', () => { + setupTestingModule(); + + const template = ''; + + const fixture = createTestComponent(template); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('style')?.replace(/\s/g, '')) + .toBe('position:absolute;width:100%;height:100%;inset:0px;'); + }); + it('should augment existing styles in fill mode', () => { + setupTestingModule(); + + const template = ''; + + const fixture = createTestComponent(template); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('style')?.replace(/\s/g, '')) + .toBe( + 'border-radius:5px;padding:10px;position:absolute;width:100%;height:100%;inset:0px;'); + }); + it('should not add fill styles if not in fill mode', () => { + setupTestingModule(); + + const template = + ''; + + const fixture = createTestComponent(template); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('style')?.replace(/\s/g, '')) + .toBe('position:relative;border-radius:5px;'); + }); + it('should add default sizes value in fill mode', () => { + setupTestingModule(); + + const template = ''; + + const fixture = createTestComponent(template); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('sizes')).toBe('100vw'); + }); + it('should not overwrite sizes value in fill mode', () => { + setupTestingModule(); + + const template = ''; + + const fixture = createTestComponent(template); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('sizes')).toBe('50vw'); + }); + it('should cause responsive srcset to be generated in fill mode', () => { + setupTestingModule(); + + const template = ''; + + const fixture = createTestComponent(template); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.getAttribute('srcset')) + .toBe( + `${IMG_BASE_URL}/path/img.png 640w, ${IMG_BASE_URL}/path/img.png 750w, ${ + IMG_BASE_URL}/path/img.png 828w, ` + + `${IMG_BASE_URL}/path/img.png 1080w, ${IMG_BASE_URL}/path/img.png 1200w, ${ + IMG_BASE_URL}/path/img.png 1920w, ` + + `${IMG_BASE_URL}/path/img.png 2048w, ${IMG_BASE_URL}/path/img.png 3840w`); + }); + }); + describe('preconnect detector', () => { const imageLoader = () => { // We need something different from the `localhost` (as we don't want to produce