From f3f7ed5cc5f63db525b22e815863c0a8a3ae211e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Mon, 18 Nov 2024 14:45:53 +0300 Subject: [PATCH] feat: configure maximum number of rows in text area (#8143) * feat: configure maximum number of rows in text area * recalculate max-height on resize and value change * avoid rounding individual height components --- .../text-area/src/vaadin-text-area-mixin.d.ts | 8 ++ .../text-area/src/vaadin-text-area-mixin.js | 46 ++++++++++- packages/text-area/test/text-area.common.js | 72 +++++++++++++++++- .../text-area/baseline/max-rows.png | Bin 0 -> 1589 bytes .../test/visual/lumo/text-area.test.js | 6 ++ .../text-area/baseline/max-rows.png | Bin 0 -> 1356 bytes .../test/visual/material/text-area.test.js | 6 ++ 7 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 packages/text-area/test/visual/lumo/screenshots/text-area/baseline/max-rows.png create mode 100644 packages/text-area/test/visual/material/screenshots/text-area/baseline/max-rows.png diff --git a/packages/text-area/src/vaadin-text-area-mixin.d.ts b/packages/text-area/src/vaadin-text-area-mixin.d.ts index 59eb31e748..7b3dcdeafe 100644 --- a/packages/text-area/src/vaadin-text-area-mixin.d.ts +++ b/packages/text-area/src/vaadin-text-area-mixin.d.ts @@ -71,6 +71,14 @@ export declare class TextAreaMixinClass { */ minRows: number; + /** + * Maximum number of rows to expand to before the text area starts scrolling. This effectively sets a max-height + * on the `input-field` part. By default, it is not set, and the text area grows with the content without + * constraints. + * @attr {number} max-rows + */ + maxRows: number | null | undefined; + /** * Scrolls the textarea to the start if it has a vertical scrollbar. */ diff --git a/packages/text-area/src/vaadin-text-area-mixin.js b/packages/text-area/src/vaadin-text-area-mixin.js index 0f6cfde835..9aa77c1b80 100644 --- a/packages/text-area/src/vaadin-text-area-mixin.js +++ b/packages/text-area/src/vaadin-text-area-mixin.js @@ -53,6 +53,16 @@ export const TextAreaMixin = (superClass) => value: 2, observer: '__minRowsChanged', }, + + /** + * Maximum number of rows to expand to before the text area starts scrolling. This effectively sets a max-height + * on the `input-field` part. By default, it is not set, and the text area grows with the content without + * constraints. + * @attr {number} max-rows + */ + maxRows: { + type: Number, + }, }; } @@ -65,7 +75,7 @@ export const TextAreaMixin = (superClass) => } static get observers() { - return ['__updateMinHeight(minRows, inputElement)']; + return ['__updateMinHeight(minRows, inputElement)', '__updateMaxHeight(maxRows, inputElement, _inputField)']; } /** @@ -192,6 +202,10 @@ export const TextAreaMixin = (superClass) => inputField.style.removeProperty('display'); inputField.style.removeProperty('height'); inputField.scrollTop = scrollTop; + + // Update max height in case this update was triggered by style changes + // affecting line height, paddings or margins. + this.__updateMaxHeight(this.maxRows); } /** @private */ @@ -209,6 +223,36 @@ export const TextAreaMixin = (superClass) => } } + /** @private */ + __updateMaxHeight(maxRows) { + if (!this._inputField || !this.inputElement) { + return; + } + + if (maxRows) { + // For maximum height, we need to constrain the height of the input + // container to prevent it from growing further. For this we take the + // line height of the native textarea times the number of rows, and add + // other properties affecting the height of the input container. + const inputStyle = getComputedStyle(this.inputElement); + const inputFieldStyle = getComputedStyle(this._inputField); + + const lineHeight = parseFloat(inputStyle.lineHeight); + const contentHeight = lineHeight * maxRows; + const marginsAndPaddings = + parseFloat(inputStyle.paddingTop) + + parseFloat(inputStyle.paddingBottom) + + parseFloat(inputStyle.marginTop) + + parseFloat(inputStyle.marginBottom) + + parseFloat(inputFieldStyle.paddingTop) + + parseFloat(inputFieldStyle.paddingBottom); + const maxHeight = Math.ceil(contentHeight + marginsAndPaddings); + this._inputField.style.setProperty('max-height', `${maxHeight}px`); + } else { + this._inputField.style.removeProperty('max-height'); + } + } + /** * @private */ diff --git a/packages/text-area/test/text-area.common.js b/packages/text-area/test/text-area.common.js index 1b75466302..403916806b 100644 --- a/packages/text-area/test/text-area.common.js +++ b/packages/text-area/test/text-area.common.js @@ -358,23 +358,28 @@ describe('text-area', () => { ); }); - describe('min rows', () => { - const lineHeight = 20; + describe('min / max rows', () => { + let lineHeight; let consoleWarn; beforeEach(async () => { + lineHeight = 20; const fixture = fixtureSync(`
`); textArea = fixture.querySelector('vaadin-text-area'); await nextUpdate(textArea); + native = textArea.querySelector('textarea'); consoleWarn = sinon.stub(console, 'warn'); }); @@ -443,6 +448,69 @@ describe('text-area', () => { expect(textArea.clientHeight).to.be.above(80); }); + + it('should use max-height based on maximum rows', async () => { + textArea.maxRows = 4; + textArea.value = Array(400).join('400'); + await nextUpdate(textArea); + + expect(textArea.clientHeight).to.equal(lineHeight * 4); + }); + + it('should include margins and paddings when calculating max-height', async () => { + const native = textArea.querySelector('textarea'); + const inputContainer = textArea.shadowRoot.querySelector('[part="input-field"]'); + native.style.paddingTop = '5px'; + native.style.paddingBottom = '10px'; + native.style.marginTop = '15px'; + native.style.marginBottom = '20px'; + inputContainer.style.paddingTop = '25px'; + inputContainer.style.paddingBottom = '30px'; + + textArea.maxRows = 4; + textArea.value = Array(400).join('400'); + await nextUpdate(textArea); + + expect(textArea.clientHeight).to.equal(lineHeight * 4 + 5 + 10 + 15 + 20 + 25 + 30); + }); + + it('should shrink below max-height defined by maximum rows', async () => { + textArea.maxRows = 4; + textArea.value = 'value'; + await nextUpdate(textArea); + + expect(textArea.clientHeight).to.be.below(lineHeight * 4); + }); + + it('should update max-height when component is resized', async () => { + textArea.maxRows = 4; + textArea.value = Array(400).join('400'); + await nextUpdate(textArea); + + // Change the line height to observe a max-height change + lineHeight = 30; + native.style.setProperty('line-height', `${lineHeight}px`); + + // Trigger a resize event + textArea._onResize(); + + expect(textArea.clientHeight).to.equal(lineHeight * 4); + }); + + it('should update max-height when value changes', async () => { + textArea.maxRows = 4; + textArea.value = Array(400).join('400'); + await nextUpdate(textArea); + + // Change the line height to observe a max-height change + lineHeight = 30; + native.style.setProperty('line-height', `${lineHeight}px`); + + // Trigger a value change + textArea.value += 'change'; + + expect(textArea.clientHeight).to.equal(lineHeight * 4); + }); }); describe('--_text-area-vertical-scroll-position CSS variable', () => { diff --git a/packages/text-area/test/visual/lumo/screenshots/text-area/baseline/max-rows.png b/packages/text-area/test/visual/lumo/screenshots/text-area/baseline/max-rows.png new file mode 100644 index 0000000000000000000000000000000000000000..0b7fac59d0cd1c89891b1d60766148c1674975a8 GIT binary patch literal 1589 zcmaKtdpHwn9LL8nEfKj@s4((oCYLlhF3WXgWb+VZ zkK1uME{SjsMn?=`m>%u1+$Cq{IX$Pxx&86JzvubB&-1>Y?;qdqn~Zft9{?W#0|0;n z7<-hnaI%GqBq<@>t@h0cghM3M8I1%~_N(v!0C7GBg>b!-zr+c$XM`_fQ^{Avbs*|L4-)6B-j9jjfLzvx>8R~({6V%US9XHyt{@Qr$0a~ zKhW5Elg9FYV^@RkKv+KdgqE?PWJe>dVM(SOG^MCRU=d3{7>so)vZQb$0T{?MZKGFA z?|ymom%E({C;4Z_ln{`Trlf8djD+mTJ^IkrRg9c@+RI|VE=!R|d;PHb-X&GZJ1*HD zM~dsB$zT>yH>VNV9CzlfR_kWWNsHzxgbd?`sPyjb zGu-lXPhb&b-1ii#XMV$J=jJEh@5YpZIqZExQ9u60gLC5(OrLFwka}I$@G3+O-+^jn zGZn75D&9^ubMKwtiZfP$J-%XPsc|60F-tqrz5j*(x)ft#<)= zjw%_spF9JTrx$kgSzFKhAWq>GYuxkgOBKv#43~|z$Vh=+;W4?NKoiVZx$&u1{n>qQovR83o4xDFS=K0(5hE8Vfm4|N{bDQ@@sxUEOet492;}H z0jaU`jDxpZi^`x+wJs3%qt$M13hhjco5aIGi>D}gmTh*Zj^nu9rQW+BlE~8~L%Gey{T4sg!lfF4Z z?iiPgto0hHzMvic?RasMZ(gRXnj3LDJ%rf=o7+*#M)ZD`&f>ua;0I2aR$VNqjPQa- z#7w)n_{QbqE6xhS<6~~$39Acfe;+ZYH~218t$yg~Tx2-c#<#e!yLkV}z?&OK_CL!Fr-hTqv&J%BTDTFCQ>L)rd4X)_uTcb5S7;EH03A zm;GB02 { element.minRows = 4; await visualDiff(div, 'min-rows'); }); + + it('max-rows', async () => { + element.value = Array(10).join('value\n'); + element.maxRows = 4; + await visualDiff(div, 'max-rows'); + }); }); diff --git a/packages/text-area/test/visual/material/screenshots/text-area/baseline/max-rows.png b/packages/text-area/test/visual/material/screenshots/text-area/baseline/max-rows.png new file mode 100644 index 0000000000000000000000000000000000000000..5615b068cb14374e34b344133fbdcc4809c2f9a4 GIT binary patch literal 1356 zcmeAS@N?(olHy`uVBq!ia0vp^SAci|2OE$q5O+-iQjEnx?oJHr&dI!FU|?nQba4!+ znDh3IZqK6(nd2Yt$N4QV^LTQ?T}eK&^HxZbT6Cw2(!>+9JhdXPm!^ma6?>+heLBG- zCI9Tz7>kYW(`KH{N%(PA`@X+XQiY~y&F3$zf9&6s?VigJ@#otmYlc1QEUXfOA_*=5 z4T6r0ZbWkS@BhNb;Q#ja_Wbnx$B&QqyYKB2jkYPT@Qc%|EhDb;O6BWYeK`9s{M^x zY-0a@&Pu&0ywhK$w659}WSBE`xw3AZpw@?=KKu4pu1gD-x^C59b#+&i=90ks)vLBf zvHLDdJ{spf_ubSHTt z#bK+X)~amXIxRI|SNyyy*DLJ8#rW=BvMhc-Yin3(cymShVnsL8uixG+RQkX*rzCKZ zc<|AaUSCZDW7dbXg>)Z&UlqLiO!3+2Q_a}#fBTpGeO>026@l~Eo_?SI+AUYLRDO5v z1I{^RQ`d`6J=QtxYsB2Fg%i$wUA#Q%XTp-RJ1*4R3V;9qc$Y!VpL?adUuk9S(wGu= zw|-OcH>Qr$LD!ehSNrmNxh}iE=C#!7zb#-fV zoW57?YU}H7ZZ(&etQT=!dUaXg^2?S#F8^$wd_dUZ=dnEfWnE8WK2Om4#y5|{ab?)7 z^V6g9rE_1krrnHqx_+CeP;1t*n@MYzxaUz|I=WOB^vq6KAkL2tf`Zg#ruxbV@rtNag2e{8t@woHGo@PXi4{F#=| zWx5n~4{$4BBu?@l(}cdX?6+2>HG#sdh!WJV(bod?5t@JEdB{m z2T{;o-oLZvfV3+_(;VlAcW=*kp4(ik@qkqTOO66*4uiM?WEWverX1LP+=uf)FJ``? zveTE}ZD+dId49gV{f|4-_2cFC#)fIcENNe4sjk+!py1%U>JT+uu$vyeVVw nXaYZ$QUc94NaY7i<`=(wiO|$LIWif*;*r7A)z4*}Q$iB}Q^#eP literal 0 HcmV?d00001 diff --git a/packages/text-area/test/visual/material/text-area.test.js b/packages/text-area/test/visual/material/text-area.test.js index 35c53ce300..6993018577 100644 --- a/packages/text-area/test/visual/material/text-area.test.js +++ b/packages/text-area/test/visual/material/text-area.test.js @@ -124,4 +124,10 @@ describe('text-area', () => { element.minRows = 4; await visualDiff(div, 'min-rows'); }); + + it('max-rows', async () => { + element.value = Array(10).join('value\n'); + element.maxRows = 4; + await visualDiff(div, 'max-rows'); + }); });