diff --git a/src/cdk/text-field/autosize.spec.ts b/src/cdk/text-field/autosize.spec.ts index 4181fdd50f55..13a0dde06b8a 100644 --- a/src/cdk/text-field/autosize.spec.ts +++ b/src/cdk/text-field/autosize.spec.ts @@ -372,6 +372,63 @@ describe('CdkTextareaAutosize', () => { expect(textarea.hasAttribute('placeholder')).toBe(false); }); + + // Regression for #32178. Issue mentions zoom, but we can't change browser zoom here; + // instead we simulate the kind of rounding/truncation that can occur in that environment. + it('should not show a scrollbar when line-height causes sub-pixel rounding', () => { + const fixture = TestBed.createComponent(AutosizeTextAreaWithDecimalLineHeight); + const textarea = fixture.nativeElement.querySelector('textarea') as HTMLTextAreaElement; + const autosize = fixture.debugElement + .query(By.css('textarea'))! + .injector.get(CdkTextareaAutosize); + + fixture.detectChanges(); + textarea.style.width = '400px'; + textarea.style.fontSize = '14px'; + + const longLine = 'a'.repeat(80); + textarea.value = `${longLine}\n${longLine}\n${longLine}a`; + + fixture.detectChanges(); + const actualNeededScrollHeight = textarea.scrollHeight; + + // Simulate fractional pixel rounding/truncation: + // - During measurement, the measuring class adds 4px padding; if we had an extra 0.5px in + // layout but it gets truncated, the value we read can be 1px smaller than the "real" need. + const scrollHeightDuringMeasurement = Math.floor(actualNeededScrollHeight + 4.5); + const actualNeededWithFractional = actualNeededScrollHeight + 0.5; + + Object.defineProperty(textarea, 'scrollHeight', { + get: function () { + if (this.classList.contains('cdk-textarea-autosize-measuring')) { + return scrollHeightDuringMeasurement; + } + return Math.ceil(actualNeededWithFractional); + }, + configurable: true, + }); + + try { + autosize.resizeToFitContent(); + fixture.detectChanges(); + } finally { + // Ensure we always restore the native property, even if the expectations fail. + delete (textarea as any).scrollHeight; + } + + const actualScrollHeight = textarea.scrollHeight; + const actualClientHeight = textarea.clientHeight; + const setHeight = parseFloat(textarea.style.height); + const heightWithBuffer = scrollHeightDuringMeasurement - 3.5; + + expect(actualClientHeight) + .withContext(`Expected no scrollbar with decimal line-height`) + .toBe(actualScrollHeight); + + expect(setHeight) + .withContext(`Set height (${setHeight}px) should be at least ${heightWithBuffer}px`) + .toBeGreaterThanOrEqual(heightWithBuffer); + }); }); // Styles to reset padding and border to make measurement comparisons easier. @@ -414,3 +471,21 @@ class AutosizeTextareaWithNgModel { class AutosizeTextareaWithoutAutosize { content: string = ''; } + +const textareaStyleWithDecimalLineHeight = ` + textarea { + padding: 0; + border: none; + overflow: auto; + line-height: 1.15; + }`; + +@Component({ + template: ` + `, + styles: textareaStyleWithDecimalLineHeight, + imports: [FormsModule, TextFieldModule], +}) +class AutosizeTextAreaWithDecimalLineHeight { + @ViewChild('autosize') autosize!: CdkTextareaAutosize; +} diff --git a/src/cdk/text-field/autosize.ts b/src/cdk/text-field/autosize.ts index 81ee7726a75f..e6fdda57b4db 100644 --- a/src/cdk/text-field/autosize.ts +++ b/src/cdk/text-field/autosize.ts @@ -253,8 +253,11 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { // Also temporarily force overflow:hidden, so scroll bars do not interfere with calculations. element.classList.add(measuringClass); // The measuring class includes a 2px padding to workaround an issue with Chrome, - // so we account for that extra space here by subtracting 4 (2px top + 2px bottom). - const scrollHeight = element.scrollHeight - 4; + // so we account for that extra space here. We subtract 3.5 (2px top + 2px bottom - 0.5px) to account + // for fractional pixel truncation that can occur with decimal line-height values. When the browser + // truncates fractional pixels (e.g., 20.4px becomes 20px), subtracting 3.5 instead of 4 + // provides a small margin that prevents scrollbars from appearing. See issue #32178. + const scrollHeight = element.scrollHeight - 3.5; element.classList.remove(measuringClass); if (needsMarginFiller) {