diff --git a/src/cdk/text-field/autosize.spec.ts b/src/cdk/text-field/autosize.spec.ts index 4181fdd50f55..fe786d280250 100644 --- a/src/cdk/text-field/autosize.spec.ts +++ b/src/cdk/text-field/autosize.spec.ts @@ -372,6 +372,39 @@ describe('CdkTextareaAutosize', () => { expect(textarea.hasAttribute('placeholder')).toBe(false); }); + + // issue ticket: #32192 + it('should correctly calculate height when textarea has padding and border-box sizing', fakeAsync(() => { + const fixture = TestBed.createComponent(AutosizeTextareaWithWidthSensitiveStyling); + const textarea = fixture.nativeElement.querySelector('textarea') as HTMLTextAreaElement; + const autosize = fixture.debugElement + .query(By.css('textarea'))! + .injector.get(CdkTextareaAutosize); + + fixture.detectChanges(); + flush(); + + // Use many short words so wrapping is highly sensitive to the available content width. + // The width-sensitive styles ensure that switching `box-sizing`/padding during measurement + // would change wrapping, which would cause the textarea to underestimate its height. + textarea.value = Array(600).fill('word').join(' '); + + fixture.detectChanges(); + autosize.resizeToFitContent(true); + flush(); + fixture.detectChanges(); + + const measuredHeight = textarea.clientHeight; + const requiredHeight = textarea.scrollHeight; + const heightDiff = requiredHeight - measuredHeight; + + expect(heightDiff) + .withContext( + `Height should match scrollHeight when measuring does not change wrapping. ` + + `Required: ${requiredHeight}px, Measured: ${measuredHeight}px, Diff: ${heightDiff}px`, + ) + .toBeLessThanOrEqual(5); + })); }); // Styles to reset padding and border to make measurement comparisons easier. @@ -414,3 +447,22 @@ class AutosizeTextareaWithNgModel { class AutosizeTextareaWithoutAutosize { content: string = ''; } + +@Component({ + template: ``, + styles: [ + textareaStyleReset, + ` + textarea.width-sensitive { + width: 120px; + box-sizing: border-box; + margin: 0; + padding: 0 24px; + word-wrap: break-word; + white-space: pre-wrap; + } + `, + ], + imports: [FormsModule, TextFieldModule], +}) +class AutosizeTextareaWithWidthSensitiveStyling {} diff --git a/src/cdk/text-field/autosize.ts b/src/cdk/text-field/autosize.ts index 81ee7726a75f..12b1919482d5 100644 --- a/src/cdk/text-field/autosize.ts +++ b/src/cdk/text-field/autosize.ts @@ -121,6 +121,8 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { /** Used to reference correct document/window */ protected _document = inject(DOCUMENT); + /** Cached reference to the current window (can be `null` in non-browser contexts). */ + private _window = this._document.defaultView; private _hasFocus = false; @@ -242,6 +244,23 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { ? 'cdk-textarea-autosize-measuring-firefox' : 'cdk-textarea-autosize-measuring'; + const previousWidth = element.style.width; + let contentWidth: number | null = null; + + // Capture the *content* width (excluding horizontal padding) before we add the measuring class, + // because that class changes padding and box-sizing which in turn changes how text wraps and + // therefore the scrollHeight. (Issue: #32192.) + const computedStyle = this._window ? this._window.getComputedStyle(element) : null; + + if (computedStyle) { + const paddingLeft = parseFloat(computedStyle.paddingLeft || '0') || 0; + const paddingRight = parseFloat(computedStyle.paddingRight || '0') || 0; + contentWidth = element.clientWidth - paddingLeft - paddingRight; + if (contentWidth <= 0) { + contentWidth = null; + } + } + // In some cases the page might move around while we're measuring the `textarea` on Firefox. We // work around it by assigning a temporary margin with the same height as the `textarea` so that // it occupies the same amount of space. See #23233. @@ -252,11 +271,23 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { // Reset the textarea height to auto in order to shrink back to its default size. // Also temporarily force overflow:hidden, so scroll bars do not interfere with calculations. element.classList.add(measuringClass); + + // When measuring, CSS applies `box-sizing: content-box` and strips horizontal padding, which + // effectively increases the available text width. To keep wrapping identical to the rendered + // textarea, lock the measuring width to the original content width we captured above. + if (contentWidth !== null) { + element.style.width = `${contentWidth}px`; + } + // 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; element.classList.remove(measuringClass); + if (contentWidth !== null) { + element.style.width = previousWidth; + } + if (needsMarginFiller) { element.style.marginBottom = previousMargin; }