Skip to content

Commit 9483343

Browse files
atcastlethePunderWoman
authored andcommitted
feat(common): Add fill mode to NgOptimizedImage (#47738)
Add a new boolean attribute to NgOptimizedImage called `fill` which does the following: * Removes the requirement for height and width * Adds inline styling to cause the image to fill its containing element * Adds a default `sizes` value of `100vw` which will cause the image to have a responsive srcset automatically generated PR Close #47738
1 parent 3a9c452 commit 9483343

File tree

4 files changed

+181
-4
lines changed

4 files changed

+181
-4
lines changed

aio/content/guide/image-directive.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ providers: [
8181

8282
</code-example>
8383

84+
### Using `fill` mode
85+
86+
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.
87+
88+
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:
89+
90+
<code-example format="typescript" language="typescript">
91+
92+
&lt;img ngSrc="cat.jpg" fill&gt;
93+
94+
</code-example>
95+
8496
### Adjusting image styling
8597

8698
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.

goldens/public-api/common/index.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,9 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
549549
set disableOptimizedSrcset(value: string | boolean | undefined);
550550
// (undocumented)
551551
get disableOptimizedSrcset(): boolean;
552+
set fill(value: string | boolean | undefined);
553+
// (undocumented)
554+
get fill(): boolean;
552555
set height(value: string | number | undefined);
553556
// (undocumented)
554557
get height(): number | undefined;
@@ -571,7 +574,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
571574
// (undocumented)
572575
get width(): number | undefined;
573576
// (undocumented)
574-
static ɵdir: i0.ɵɵDirectiveDeclaration<NgOptimizedImage, "img[ngSrc],img[rawSrc]", never, { "rawSrc": "rawSrc"; "ngSrc": "ngSrc"; "ngSrcset": "ngSrcset"; "sizes": "sizes"; "width": "width"; "height": "height"; "loading": "loading"; "priority": "priority"; "disableOptimizedSrcset": "disableOptimizedSrcset"; "src": "src"; "srcset": "srcset"; }, {}, never, never, true, never>;
577+
static ɵdir: i0.ɵɵDirectiveDeclaration<NgOptimizedImage, "img[ngSrc],img[rawSrc]", never, { "rawSrc": "rawSrc"; "ngSrc": "ngSrc"; "ngSrcset": "ngSrcset"; "sizes": "sizes"; "width": "width"; "height": "height"; "loading": "loading"; "priority": "priority"; "disableOptimizedSrcset": "disableOptimizedSrcset"; "fill": "fill"; "src": "src"; "srcset": "srcset"; }, {}, never, never, true, never>;
575578
// (undocumented)
576579
static ɵfac: i0.ɵɵFactoryDeclaration<NgOptimizedImage, never>;
577580
}

packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,12 @@ export const IMAGE_CONFIG = new InjectionToken<ImageConfig>(
202202
@Directive({
203203
standalone: true,
204204
selector: 'img[ngSrc],img[rawSrc]',
205+
host: {
206+
'[style.position]': 'fill ? "absolute" : null',
207+
'[style.width]': 'fill ? "100%" : null',
208+
'[style.height]': 'fill ? "100%" : null',
209+
'[style.inset]': 'fill ? "0px" : null'
210+
}
205211
})
206212
export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
207213
private imageLoader = inject(IMAGE_LOADER);
@@ -330,6 +336,19 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
330336
}
331337
private _disableOptimizedSrcset = false;
332338

339+
/**
340+
* Sets the image to "fill mode," which eliminates the height/width requirement and adds
341+
* styles such that the image fills its containing element.
342+
*/
343+
@Input()
344+
set fill(value: string|boolean|undefined) {
345+
this._fill = inputToBoolean(value);
346+
}
347+
get fill(): boolean {
348+
return this._fill;
349+
}
350+
private _fill = false;
351+
333352
/**
334353
* Value of the `src` attribute if set on the host `<img>` element.
335354
* 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 {
356375
}
357376
assertNotBase64Image(this);
358377
assertNotBlobUrl(this);
359-
assertNonEmptyWidthAndHeight(this);
378+
if (this.fill) {
379+
assertEmptyWidthAndHeight(this);
380+
} else {
381+
assertNonEmptyWidthAndHeight(this);
382+
}
360383
assertValidLoadingInput(this);
361384
assertNoImageDistortion(this, this.imgElement, this.renderer);
362385
if (!this.ngSrcset) {
@@ -383,8 +406,14 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
383406
private setHostAttributes() {
384407
// Must set width/height explicitly in case they are bound (in which case they will
385408
// only be reflected and not found by the browser)
386-
this.setHostAttribute('width', this.width!.toString());
387-
this.setHostAttribute('height', this.height!.toString());
409+
if (this.fill) {
410+
if (!this.sizes) {
411+
this.sizes = '100vw';
412+
}
413+
} else {
414+
this.setHostAttribute('width', this.width!.toString());
415+
this.setHostAttribute('height', this.height!.toString());
416+
}
388417

389418
this.setHostAttribute('loading', this.getLoadingBehavior());
390419
this.setHostAttribute('fetchpriority', this.getFetchPriority());
@@ -805,6 +834,22 @@ function assertNonEmptyWidthAndHeight(dir: NgOptimizedImage) {
805834
}
806835
}
807836

837+
/**
838+
* Verifies that width and height are not set. Used in fill mode, where those attributes don't make
839+
* sense.
840+
*/
841+
function assertEmptyWidthAndHeight(dir: NgOptimizedImage) {
842+
if (dir.width || dir.height) {
843+
throw new RuntimeError(
844+
RuntimeErrorCode.INVALID_INPUT,
845+
`${
846+
imgDirectiveDetails(
847+
dir.ngSrc)} the attributes \`height\` and/or \`width\` are present ` +
848+
`along with the \`fill\` attribute. Because \`fill\` mode causes an image to fill its containing ` +
849+
`element, the size attributes have no effect and should be removed.`);
850+
}
851+
}
852+
808853
/**
809854
* Verifies that the `loading` attribute is set to a valid input &
810855
* is not used on priority images.

packages/common/test/directives/ng_optimized_image_spec.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,123 @@ describe('Image directive', () => {
794794
});
795795
});
796796

797+
describe('fill mode', () => {
798+
it('should allow unsized images in fill mode', () => {
799+
setupTestingModule();
800+
801+
const template = '<img ngSrc="path/img.png" fill>';
802+
expect(() => {
803+
const fixture = createTestComponent(template);
804+
fixture.detectChanges();
805+
}).not.toThrow();
806+
});
807+
it('should throw if width is provided for fill mode image', () => {
808+
setupTestingModule();
809+
810+
const template = '<img ngSrc="path/img.png" width="500" fill>';
811+
expect(() => {
812+
const fixture = createTestComponent(template);
813+
fixture.detectChanges();
814+
})
815+
.toThrowError(
816+
'NG02952: The NgOptimizedImage directive (activated on an <img> element with the ' +
817+
'`ngSrc="path/img.png"`) has detected that the attributes `height` and/or `width` ' +
818+
'are present along with the `fill` attribute. Because `fill` mode causes an image ' +
819+
'to fill its containing element, the size attributes have no effect and should be removed.');
820+
});
821+
it('should throw if height is provided for fill mode image', () => {
822+
setupTestingModule();
823+
824+
const template = '<img ngSrc="path/img.png" height="500" fill>';
825+
expect(() => {
826+
const fixture = createTestComponent(template);
827+
fixture.detectChanges();
828+
})
829+
.toThrowError(
830+
'NG02952: The NgOptimizedImage directive (activated on an <img> element with the ' +
831+
'`ngSrc="path/img.png"`) has detected that the attributes `height` and/or `width` ' +
832+
'are present along with the `fill` attribute. Because `fill` mode causes an image ' +
833+
'to fill its containing element, the size attributes have no effect and should be removed.');
834+
});
835+
it('should apply appropriate styles in fill mode', () => {
836+
setupTestingModule();
837+
838+
const template = '<img ngSrc="path/img.png" fill>';
839+
840+
const fixture = createTestComponent(template);
841+
fixture.detectChanges();
842+
const nativeElement = fixture.nativeElement as HTMLElement;
843+
const img = nativeElement.querySelector('img')!;
844+
expect(img.getAttribute('style')?.replace(/\s/g, ''))
845+
.toBe('position:absolute;width:100%;height:100%;inset:0px;');
846+
});
847+
it('should augment existing styles in fill mode', () => {
848+
setupTestingModule();
849+
850+
const template = '<img ngSrc="path/img.png" style="border-radius: 5px; padding: 10px" fill>';
851+
852+
const fixture = createTestComponent(template);
853+
fixture.detectChanges();
854+
const nativeElement = fixture.nativeElement as HTMLElement;
855+
const img = nativeElement.querySelector('img')!;
856+
expect(img.getAttribute('style')?.replace(/\s/g, ''))
857+
.toBe(
858+
'border-radius:5px;padding:10px;position:absolute;width:100%;height:100%;inset:0px;');
859+
});
860+
it('should not add fill styles if not in fill mode', () => {
861+
setupTestingModule();
862+
863+
const template =
864+
'<img ngSrc="path/img.png" width="400" height="300" style="position: relative; border-radius: 5px">';
865+
866+
const fixture = createTestComponent(template);
867+
fixture.detectChanges();
868+
const nativeElement = fixture.nativeElement as HTMLElement;
869+
const img = nativeElement.querySelector('img')!;
870+
expect(img.getAttribute('style')?.replace(/\s/g, ''))
871+
.toBe('position:relative;border-radius:5px;');
872+
});
873+
it('should add default sizes value in fill mode', () => {
874+
setupTestingModule();
875+
876+
const template = '<img ngSrc="path/img.png" fill>';
877+
878+
const fixture = createTestComponent(template);
879+
fixture.detectChanges();
880+
const nativeElement = fixture.nativeElement as HTMLElement;
881+
const img = nativeElement.querySelector('img')!;
882+
expect(img.getAttribute('sizes')).toBe('100vw');
883+
});
884+
it('should not overwrite sizes value in fill mode', () => {
885+
setupTestingModule();
886+
887+
const template = '<img ngSrc="path/img.png" sizes="50vw" fill>';
888+
889+
const fixture = createTestComponent(template);
890+
fixture.detectChanges();
891+
const nativeElement = fixture.nativeElement as HTMLElement;
892+
const img = nativeElement.querySelector('img')!;
893+
expect(img.getAttribute('sizes')).toBe('50vw');
894+
});
895+
it('should cause responsive srcset to be generated in fill mode', () => {
896+
setupTestingModule();
897+
898+
const template = '<img ngSrc="path/img.png" fill>';
899+
900+
const fixture = createTestComponent(template);
901+
fixture.detectChanges();
902+
const nativeElement = fixture.nativeElement as HTMLElement;
903+
const img = nativeElement.querySelector('img')!;
904+
expect(img.getAttribute('srcset'))
905+
.toBe(
906+
`${IMG_BASE_URL}/path/img.png 640w, ${IMG_BASE_URL}/path/img.png 750w, ${
907+
IMG_BASE_URL}/path/img.png 828w, ` +
908+
`${IMG_BASE_URL}/path/img.png 1080w, ${IMG_BASE_URL}/path/img.png 1200w, ${
909+
IMG_BASE_URL}/path/img.png 1920w, ` +
910+
`${IMG_BASE_URL}/path/img.png 2048w, ${IMG_BASE_URL}/path/img.png 3840w`);
911+
});
912+
});
913+
797914
describe('preconnect detector', () => {
798915
const imageLoader = () => {
799916
// We need something different from the `localhost` (as we don't want to produce

0 commit comments

Comments
 (0)