From 78b1f7a6632e9ac820fdc9e50d62c094474d752d Mon Sep 17 00:00:00 2001 From: originalajitest Date: Sat, 3 Jan 2026 20:59:46 -0800 Subject: [PATCH 1/2] fix(cdk/autosize): attempt at fixing variable width textfield autosize bug --- src/cdk/text-field/autosize.spec.ts | 110 ++++++++++++++++++++++++++++ src/cdk/text-field/autosize.ts | 31 ++++++++ 2 files changed, 141 insertions(+) diff --git a/src/cdk/text-field/autosize.spec.ts b/src/cdk/text-field/autosize.spec.ts index 4181fdd50f55..8a43d8762e04 100644 --- a/src/cdk/text-field/autosize.spec.ts +++ b/src/cdk/text-field/autosize.spec.ts @@ -372,6 +372,85 @@ describe('CdkTextareaAutosize', () => { expect(textarea.hasAttribute('placeholder')).toBe(false); }); + + it('should correctly calculate height when parent container has a scrollbar', fakeAsync(() => { + const fixtureWithScrollableParent = TestBed.createComponent( + AutosizeTextareaWithScrollableParent, + ); + const container = + fixtureWithScrollableParent.nativeElement.querySelector('.scrollable-container'); + const textareaInContainer = fixtureWithScrollableParent.nativeElement.querySelector('textarea'); + const autosizeInContainer = fixtureWithScrollableParent.debugElement + .query(By.css('textarea'))! + .injector.get(CdkTextareaAutosize); + + fixtureWithScrollableParent.detectChanges(); + flush(); + + container.style.width = '110px'; + container.style.height = '200px'; + fixtureWithScrollableParent.detectChanges(); + + const baseText = Array(18) + .fill( + 'This is text that will cause the container to show a scrollbar when the height is reduced significantly.', + ) + .join(' '); + + textareaInContainer.value = baseText; + fixtureWithScrollableParent.detectChanges(); + autosizeInContainer.resizeToFitContent(true); + flush(); + fixtureWithScrollableParent.detectChanges(); + + container.style.height = '60px'; + fixtureWithScrollableParent.detectChanges(); + autosizeInContainer.resizeToFitContent(true); + flush(); + fixtureWithScrollableParent.detectChanges(); + + const hasScrollbar = container.scrollHeight > container.clientHeight; + expect(hasScrollbar).withContext('Container must have scrollbar').toBe(true); + + if (!hasScrollbar) { + return; + } + + // Capture the width with scrollbar present + // Note: With box-sizing: border-box and width: 100%, the scrollbar may not reduce clientWidth + // but it still affects the available content width for text wrapping + const widthWithScrollbar = textareaInContainer.clientWidth; + + // Add text that will wrap differently due to the reduced width + // Use many repetitions to ensure wrapping is sensitive to width changes + const additionalText = Array(60) + .fill( + 'More text with many short words that wrap frequently when width is reduced by scrollbar in narrow container.', + ) + .join(' '); + + textareaInContainer.value = baseText + ' ' + additionalText; + fixtureWithScrollableParent.detectChanges(); + autosizeInContainer.resizeToFitContent(true); + flush(); + fixtureWithScrollableParent.detectChanges(); + + // Verify scrollbar is still present + expect(container.scrollHeight) + .withContext('Scrollbar should still be present') + .toBeGreaterThan(container.clientHeight); + + const measuredHeight = textareaInContainer.clientHeight; + const requiredHeight = textareaInContainer.scrollHeight; + const heightDiff = requiredHeight - measuredHeight; + + expect(heightDiff) + .withContext( + `Height should match scrollHeight when parent has scrollbar. ` + + `Required: ${requiredHeight}px, Measured: ${measuredHeight}px, Diff: ${heightDiff}px`, + ) + .toBeLessThanOrEqual(5); + })); }); // Styles to reset padding and border to make measurement comparisons easier. @@ -414,3 +493,34 @@ class AutosizeTextareaWithNgModel { class AutosizeTextareaWithoutAutosize { content: string = ''; } + +@Component({ + template: ` +
+ +
+ `, + styles: [ + textareaStyleReset, + ` + .scrollable-container { + width: 180px; + height: 150px; + overflow-y: auto; + padding: 0; + border: 1px solid #ccc; + box-sizing: border-box; + } + .scrollable-container textarea { + width: 100%; + box-sizing: border-box; + margin: 0; + padding: 2px; + word-wrap: break-word; + white-space: pre-wrap; + } + `, + ], + imports: [FormsModule, TextFieldModule], +}) +class AutosizeTextareaWithScrollableParent {} diff --git a/src/cdk/text-field/autosize.ts b/src/cdk/text-field/autosize.ts index 81ee7726a75f..ac560f99feae 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); + /** Used to get width of text field */ + 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 + 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 idential to the rendered + // textarea, lock the measuring width to the original conent 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; } From 66a2dd62261d38b70fb0e1256765fc1abaf5a4e4 Mon Sep 17 00:00:00 2001 From: originalajitest Date: Sat, 3 Jan 2026 23:34:24 -0800 Subject: [PATCH 2/2] fix(cdk/autosize): fixed tests --- src/cdk/text-field/autosize.spec.ts | 100 ++++++---------------------- src/cdk/text-field/autosize.ts | 14 ++-- 2 files changed, 28 insertions(+), 86 deletions(-) diff --git a/src/cdk/text-field/autosize.spec.ts b/src/cdk/text-field/autosize.spec.ts index 8a43d8762e04..fe786d280250 100644 --- a/src/cdk/text-field/autosize.spec.ts +++ b/src/cdk/text-field/autosize.spec.ts @@ -373,80 +373,34 @@ describe('CdkTextareaAutosize', () => { expect(textarea.hasAttribute('placeholder')).toBe(false); }); - it('should correctly calculate height when parent container has a scrollbar', fakeAsync(() => { - const fixtureWithScrollableParent = TestBed.createComponent( - AutosizeTextareaWithScrollableParent, - ); - const container = - fixtureWithScrollableParent.nativeElement.querySelector('.scrollable-container'); - const textareaInContainer = fixtureWithScrollableParent.nativeElement.querySelector('textarea'); - const autosizeInContainer = fixtureWithScrollableParent.debugElement + // 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); - fixtureWithScrollableParent.detectChanges(); - flush(); - - container.style.width = '110px'; - container.style.height = '200px'; - fixtureWithScrollableParent.detectChanges(); - - const baseText = Array(18) - .fill( - 'This is text that will cause the container to show a scrollbar when the height is reduced significantly.', - ) - .join(' '); - - textareaInContainer.value = baseText; - fixtureWithScrollableParent.detectChanges(); - autosizeInContainer.resizeToFitContent(true); - flush(); - fixtureWithScrollableParent.detectChanges(); - - container.style.height = '60px'; - fixtureWithScrollableParent.detectChanges(); - autosizeInContainer.resizeToFitContent(true); + fixture.detectChanges(); flush(); - fixtureWithScrollableParent.detectChanges(); - - const hasScrollbar = container.scrollHeight > container.clientHeight; - expect(hasScrollbar).withContext('Container must have scrollbar').toBe(true); - - if (!hasScrollbar) { - return; - } - // Capture the width with scrollbar present - // Note: With box-sizing: border-box and width: 100%, the scrollbar may not reduce clientWidth - // but it still affects the available content width for text wrapping - const widthWithScrollbar = textareaInContainer.clientWidth; + // 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(' '); - // Add text that will wrap differently due to the reduced width - // Use many repetitions to ensure wrapping is sensitive to width changes - const additionalText = Array(60) - .fill( - 'More text with many short words that wrap frequently when width is reduced by scrollbar in narrow container.', - ) - .join(' '); - - textareaInContainer.value = baseText + ' ' + additionalText; - fixtureWithScrollableParent.detectChanges(); - autosizeInContainer.resizeToFitContent(true); + fixture.detectChanges(); + autosize.resizeToFitContent(true); flush(); - fixtureWithScrollableParent.detectChanges(); - - // Verify scrollbar is still present - expect(container.scrollHeight) - .withContext('Scrollbar should still be present') - .toBeGreaterThan(container.clientHeight); + fixture.detectChanges(); - const measuredHeight = textareaInContainer.clientHeight; - const requiredHeight = textareaInContainer.scrollHeight; + const measuredHeight = textarea.clientHeight; + const requiredHeight = textarea.scrollHeight; const heightDiff = requiredHeight - measuredHeight; expect(heightDiff) .withContext( - `Height should match scrollHeight when parent has scrollbar. ` + + `Height should match scrollHeight when measuring does not change wrapping. ` + `Required: ${requiredHeight}px, Measured: ${measuredHeight}px, Diff: ${heightDiff}px`, ) .toBeLessThanOrEqual(5); @@ -495,27 +449,15 @@ class AutosizeTextareaWithoutAutosize { } @Component({ - template: ` -
- -
- `, + template: ``, styles: [ textareaStyleReset, ` - .scrollable-container { - width: 180px; - height: 150px; - overflow-y: auto; - padding: 0; - border: 1px solid #ccc; - box-sizing: border-box; - } - .scrollable-container textarea { - width: 100%; + textarea.width-sensitive { + width: 120px; box-sizing: border-box; margin: 0; - padding: 2px; + padding: 0 24px; word-wrap: break-word; white-space: pre-wrap; } @@ -523,4 +465,4 @@ class AutosizeTextareaWithoutAutosize { ], imports: [FormsModule, TextFieldModule], }) -class AutosizeTextareaWithScrollableParent {} +class AutosizeTextareaWithWidthSensitiveStyling {} diff --git a/src/cdk/text-field/autosize.ts b/src/cdk/text-field/autosize.ts index ac560f99feae..12b1919482d5 100644 --- a/src/cdk/text-field/autosize.ts +++ b/src/cdk/text-field/autosize.ts @@ -121,8 +121,8 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { /** Used to reference correct document/window */ protected _document = inject(DOCUMENT); - /** Used to get width of text field */ - private window = this._document.defaultView; + /** Cached reference to the current window (can be `null` in non-browser contexts). */ + private _window = this._document.defaultView; private _hasFocus = false; @@ -248,9 +248,9 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { 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 - const computedStyle = this.window ? this.window.getComputedStyle(element) : null; + // 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; @@ -273,8 +273,8 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { 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 idential to the rendered - // textarea, lock the measuring width to the original conent width we captured above + // 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`; }