From 288a80ccfeb0fff13915746aaebc59bcb1a5b10e Mon Sep 17 00:00:00 2001 From: mimshins Date: Mon, 5 May 2025 18:48:30 +0330 Subject: [PATCH 1/2] add auto-resize feature --- .changeset/tidy-toys-pull.md | 6 + .../src/base-text-input/base-text-input.ts | 2 +- .../web-components/src/text-area/constants.ts | 6 + .../src/text-area/text-area.style.ts | 30 ++- .../src/text-area/text-area.test.ts | 81 ++++++- .../web-components/src/text-area/text-area.ts | 213 +++++++++++++++++- .../web-components/src/text-area/utils.ts | 10 + 7 files changed, 331 insertions(+), 17 deletions(-) create mode 100644 .changeset/tidy-toys-pull.md create mode 100644 packages/web-components/src/text-area/constants.ts create mode 100644 packages/web-components/src/text-area/utils.ts diff --git a/.changeset/tidy-toys-pull.md b/.changeset/tidy-toys-pull.md new file mode 100644 index 00000000..0c332cd4 --- /dev/null +++ b/.changeset/tidy-toys-pull.md @@ -0,0 +1,6 @@ +--- +"@tapsioss/web-components": minor +--- + +Add auto-resize feature to text-area component + \ No newline at end of file diff --git a/packages/web-components/src/base-text-input/base-text-input.ts b/packages/web-components/src/base-text-input/base-text-input.ts index e5368f59..49735207 100644 --- a/packages/web-components/src/base-text-input/base-text-input.ts +++ b/packages/web-components/src/base-text-input/base-text-input.ts @@ -305,7 +305,7 @@ export abstract class BaseTextInput extends BaseInput { >("#input"); } - protected handleInput(event: InputEvent): void { + protected handleInput(event: Event): void { this._dirty = true; this.value = (event.target as HTMLInputElement).value; } diff --git a/packages/web-components/src/text-area/constants.ts b/packages/web-components/src/text-area/constants.ts new file mode 100644 index 00000000..b647c219 --- /dev/null +++ b/packages/web-components/src/text-area/constants.ts @@ -0,0 +1,6 @@ +export const ErrorMessages = { + SET_VALID_MIN_ROWS: [ + "Expected a valid `min-rows`.", + "The `min-rows` property should be less than `rows` which has a default value of 2.", + ].join(" "), +} as const; diff --git a/packages/web-components/src/text-area/text-area.style.ts b/packages/web-components/src/text-area/text-area.style.ts index cb24a85c..f2febe5e 100644 --- a/packages/web-components/src/text-area/text-area.style.ts +++ b/packages/web-components/src/text-area/text-area.style.ts @@ -2,14 +2,40 @@ import { css, type CSSResult } from "lit"; const styles: CSSResult = css` .control { - height: 6.5rem; + height: auto; padding: var(--tapsi-spacing-5) var(--tapsi-spacing-6); align-items: flex-start; } - .input { + .input, + .shadow { resize: none; + padding: 0; + border: none; + /* For Internet Explorer and older Edge */ + -ms-overflow-style: none; + /* For Firefox and newer browsers */ + scrollbar-width: none; + } + + .input::-webkit-scrollbar, + .shadow::-webkit-scrollbar { + /* Also works for WebKit */ + display: none; + } + + .shadow { + pointer-events: none; + /* Remove from the content flow */ + position: absolute; + visibility: hidden; + /* Ignore the scrollbar width */ + overflow: hidden; + top: 0; + height: 0; + /* Create a new layer, increase the isolation of the computed values */ + isolation: isolate; } `; diff --git a/packages/web-components/src/text-area/text-area.test.ts b/packages/web-components/src/text-area/text-area.test.ts index f2f15370..c8f27dbd 100644 --- a/packages/web-components/src/text-area/text-area.test.ts +++ b/packages/web-components/src/text-area/text-area.test.ts @@ -1,14 +1,18 @@ import { afterEach, + createPromiseResolvers, describe, disposeMocks, expect, render, test, } from "@internals/test-helpers"; -import { ErrorMessages } from "../base-text-input/constants.ts"; +import { ErrorMessages as BaseErrorMessages } from "../base-text-input/constants.ts"; +import { ErrorMessages } from "./constants.ts"; describe("🧩 text-area", () => { + const scope = "text-area"; + afterEach(async ({ page }) => { await disposeMocks(page); }); @@ -49,11 +53,17 @@ describe("🧩 text-area", () => { test("🧪 should throw error if no valid label was set for the input", async ({ page, }) => { - const errors: string[] = []; + const msgResolver = createPromiseResolvers(); page.on("console", msg => { - if (msg.type() === "error") { - errors.push(msg.text()); + if ( + msg.type() === "error" && + msg.text().includes(scope) && + msg + .text() + .includes(BaseErrorMessages.SET_VALID_LABEL_OR_LABELLEDBY_ATTRIBUTE) + ) { + msgResolver.resolve(msg.text()); } }); @@ -62,9 +72,9 @@ describe("🧩 text-area", () => { ``, ); - expect(errors[0]).toContain( - ErrorMessages.SET_VALID_LABEL_OR_LABELLEDBY_ATTRIBUTE, - ); + const msg = await msgResolver.promise; + + expect(msg).toBeDefined(); }); test("🧪 should render slots", async ({ page }) => { @@ -147,4 +157,61 @@ describe("🧩 text-area", () => { await label.click(); await expect(input).toBeFocused(); }); + + test("🧪 should be auto-resizable when `min-rows` exists", async ({ + page, + }) => { + const maxRows = 5; + + await render( + page, + ` + + `, + ); + + const input = page.locator('#test-text-area [part="input"]'); + + const getHeight = () => + input.evaluate((el: HTMLTextAreaElement) => el.offsetHeight); + + const singleRowHeight = await getHeight(); + + expect(singleRowHeight).toBeTruthy(); + + await input.focus(); + + for (let rows = 1; rows < maxRows; rows++) { + await page.keyboard.press("Enter"); + + expect(await getHeight()).toBe(singleRowHeight * (rows + 1)); + } + + await page.keyboard.press("Enter"); + + expect(await getHeight()).toBe(singleRowHeight * 5); + + const msgResolver = createPromiseResolvers(); + + page.on("console", msg => { + if ( + msg.type() === "warning" && + msg.text().includes(scope) && + msg.text().includes(ErrorMessages.SET_VALID_MIN_ROWS) + ) { + msgResolver.resolve(msg.text()); + } + }); + + await render( + page, + ` + + `, + ); + + const msg = await msgResolver.promise; + + expect(msg).toBeDefined(); + }); }); diff --git a/packages/web-components/src/text-area/text-area.ts b/packages/web-components/src/text-area/text-area.ts index 8bd7f689..903a0d1c 100644 --- a/packages/web-components/src/text-area/text-area.ts +++ b/packages/web-components/src/text-area/text-area.ts @@ -1,18 +1,34 @@ -import { html, nothing, type CSSResultGroup, type TemplateResult } from "lit"; -import { property } from "lit/decorators.js"; +import { + html, + nothing, + type CSSResultGroup, + type PropertyValues, + type TemplateResult, +} from "lit"; +import { property, state } from "lit/decorators.js"; import { live } from "lit/directives/live.js"; +import { createRef, ref } from "lit/directives/ref.js"; +import { styleMap } from "lit/directives/style-map.js"; import BaseTextInput, { baseTextInputStyles, } from "../base-text-input/index.ts"; -import { createValidator, logger, type Validator } from "../utils/index.ts"; +import { + createValidator, + getWindow, + isResizeSensorSupported, + logger, + ResizeSensor, + type Validator, +} from "../utils/index.ts"; import TextAreaValidator from "./Validator.ts"; +import { ErrorMessages } from "./constants.ts"; import styles from "./text-area.style.ts"; +import { getStyleValue } from "./utils.ts"; /** * @summary A multi-line input that enables user to type in text information. * * @tag tapsi-text-area - * */ export class TextArea extends BaseTextInput { /** @internal */ @@ -33,6 +49,17 @@ export class TextArea extends BaseTextInput { @property({ type: Number }) public rows = 2; + /** + * Minimum number of rows to display. + * If specified will automatically adjust the height. + * + * @prop {number} minRows + * @attr {string} min-rows + * @default NaN + */ + @property({ type: Number, attribute: "min-rows" }) + public minRows: number = NaN; + /** * The number of cols to display for the text input. * @@ -59,6 +86,59 @@ export class TextArea extends BaseTextInput { @property() public override inputMode = ""; + @state() + private _inputStyleState: { + overflow?: boolean; + outerHeightStyle?: number; + } = {}; + + private _shadowRef = createRef(); + private _inputRef = createRef(); + + private readonly _resizeSensor: ResizeSensor | null = null; + + constructor() { + super(); + + this._handleChange = this._handleChange.bind(this); + this._handleInput = this._handleInput.bind(this); + + const shouldSpinupSensor = + isResizeSensorSupported() && this._isAutoResizable(); + + this._resizeSensor = shouldSpinupSensor + ? new ResizeSensor(() => { + this._syncHeights(); + }, 120) + : null; + } + + private _isAutoResizable() { + if (Number.isNaN(this.minRows)) return false; + + return this.minRows >= 1 && this.minRows < this.rows; + } + + /** @internal */ + public override connectedCallback(): void { + super.connectedCallback(); + + this._resizeSensor?.observe(this); + } + + /** @internal */ + public override disconnectedCallback(): void { + super.disconnectedCallback(); + + this._resizeSensor?.disconnect(); + } + + protected override firstUpdated(changed: PropertyValues): void { + super.firstUpdated(changed); + + if (this.value) this._syncHeights(); + } + /** @internal */ public override [createValidator](): Validator { return new TextAreaValidator(() => ({ @@ -67,6 +147,114 @@ export class TextArea extends BaseTextInput { })); } + private _syncHeights() { + if (!this._isAutoResizable()) return; + + const input = this._inputRef.value; + const shadow = this._shadowRef.value; + + if (!input || !shadow) return; + + const ownerWindow = getWindow(input); + const inputComputedStyle = ownerWindow.getComputedStyle(input); + + // If input's width is shrunk and it's not visible, don't sync height. + if (inputComputedStyle.width === "0px") return; + + shadow.style.width = inputComputedStyle.width; + shadow.value = input.value || this.placeholder || " "; + + if (shadow.value.slice(-1) === "\n") { + // Certain fonts which overflow the line height will cause the textarea + // to report a different scrollHeight depending on whether the last line + // is empty. Make it non-empty to avoid this issue. + shadow.value += " "; + } + + const boxSizing = inputComputedStyle.boxSizing; + + const padding = + getStyleValue(inputComputedStyle, "paddingBottom") + + getStyleValue(inputComputedStyle, "paddingTop"); + + const border = + getStyleValue(inputComputedStyle, "borderBottomWidth") + + getStyleValue(inputComputedStyle, "borderTopWidth"); + + // The height of the inner content + const innerHeight = shadow.scrollHeight; + + // Measure height of a textarea with a single row + shadow.value = " "; + const singleRowHeight = shadow.scrollHeight; + + // The height of the outer content + let outerHeight = innerHeight; + + if (this.minRows) { + outerHeight = Math.max( + Number(this.minRows) * singleRowHeight, + outerHeight, + ); + } + + if (this.rows) { + outerHeight = Math.min(Number(this.rows) * singleRowHeight, outerHeight); + } + + outerHeight = Math.max(outerHeight, singleRowHeight); + + // Take the box sizing into account for applying this value as a style. + const outerHeightStyle = + outerHeight + (boxSizing === "border-box" ? padding + border : 0); + + const overflow = Math.abs(outerHeight - innerHeight) <= 1; + + if ( + (outerHeightStyle > 0 && + Math.abs( + (this._inputStyleState.outerHeightStyle || 0) - outerHeightStyle, + ) > 1) || + this._inputStyleState.overflow !== overflow + ) { + this._inputStyleState = { + overflow, + outerHeightStyle, + }; + } + } + + private _handleInput(event: Event) { + this._syncHeights(); + this.handleInput(event); + } + + private _handleChange(event: Event) { + this._syncHeights(); + this.handleChange(event); + } + + private _renderShadowTextArea() { + if (!Number.isNaN(this.minRows) && !this._isAutoResizable()) { + logger(ErrorMessages.SET_VALID_MIN_ROWS, "text-area", "warning"); + } + + if (!this._isAutoResizable()) return null; + + const shadowStyles = styleMap({}); + + return html` + + `; + } + protected override renderInput(): TemplateResult { if (!this.hasValidLabel()) { logger( @@ -86,8 +274,18 @@ export class TextArea extends BaseTextInput { const maxLength = this.maxLength === -1 ? nothing : this.maxLength; const minLength = this.minLength === -1 ? nothing : this.minLength; + const inputStyles = styleMap({ + height: + typeof this._inputStyleState.outerHeightStyle !== "undefined" + ? `${this._inputStyleState.outerHeightStyle}px` + : undefined, + overflow: this._inputStyleState.overflow ? "hidden" : undefined, + }); + return html` + ${this._renderShadowTextArea()} `; } } diff --git a/packages/web-components/src/text-area/utils.ts b/packages/web-components/src/text-area/utils.ts new file mode 100644 index 00000000..312e55f3 --- /dev/null +++ b/packages/web-components/src/text-area/utils.ts @@ -0,0 +1,10 @@ +export const getStyleValue = ( + computedStyle: CSSStyleDeclaration, + property: keyof CSSStyleDeclaration, +): number => { + const style = computedStyle[property] as string | null | undefined; + + if (!style) return 0; + + return parseInt(style, 10); +}; From cb6257c3154ed67e149f0a0bd5acb5c695ba278a Mon Sep 17 00:00:00 2001 From: mimshins Date: Mon, 5 May 2025 18:52:02 +0330 Subject: [PATCH 2/2] remove redundant styleMap --- packages/web-components/src/text-area/text-area.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/web-components/src/text-area/text-area.ts b/packages/web-components/src/text-area/text-area.ts index 903a0d1c..840d34be 100644 --- a/packages/web-components/src/text-area/text-area.ts +++ b/packages/web-components/src/text-area/text-area.ts @@ -241,8 +241,6 @@ export class TextArea extends BaseTextInput { if (!this._isAutoResizable()) return null; - const shadowStyles = styleMap({}); - return html` `; }