diff --git a/core/api.txt b/core/api.txt index e4597ef5934..57d77a242c0 100644 --- a/core/api.txt +++ b/core/api.txt @@ -2423,7 +2423,7 @@ ion-text,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "second ion-text,prop,mode,"ios" | "md",undefined,false,false ion-text,prop,theme,"ios" | "md" | "ionic",undefined,false,false -ion-textarea,scoped +ion-textarea,shadow ion-textarea,prop,autoGrow,boolean,false,false,true ion-textarea,prop,autocapitalize,string,'none',false,false ion-textarea,prop,autofocus,boolean,false,false,false @@ -2447,7 +2447,7 @@ ion-textarea,prop,mode,"ios" | "md",undefined,false,false ion-textarea,prop,name,string,this.inputId,false,false ion-textarea,prop,placeholder,string | undefined,undefined,false,false ion-textarea,prop,readonly,boolean,false,false,false -ion-textarea,prop,required,boolean,false,false,false +ion-textarea,prop,required,boolean,false,false,true ion-textarea,prop,rows,number | undefined,undefined,false,false ion-textarea,prop,shape,"rectangular" | "round" | "soft" | undefined,undefined,false,false ion-textarea,prop,size,"large" | "medium" | "small" | undefined,'medium',false,false @@ -2515,6 +2515,14 @@ ion-textarea,css-prop,--placeholder-font-weight,md ion-textarea,css-prop,--placeholder-opacity,ionic ion-textarea,css-prop,--placeholder-opacity,ios ion-textarea,css-prop,--placeholder-opacity,md +ion-textarea,part,bottom +ion-textarea,part,container +ion-textarea,part,counter +ion-textarea,part,error-text +ion-textarea,part,helper-text +ion-textarea,part,label +ion-textarea,part,native +ion-textarea,part,supporting-text ion-thumbnail,shadow ion-thumbnail,prop,mode,"ios" | "md",undefined,false,false diff --git a/core/src/components/textarea/test/bottom-content/textarea.e2e.ts b/core/src/components/textarea/test/bottom-content/textarea.e2e.ts index 7c7315c4857..6f9f357706c 100644 --- a/core/src/components/textarea/test/bottom-content/textarea.e2e.ts +++ b/core/src/components/textarea/test/bottom-content/textarea.e2e.ts @@ -157,12 +157,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co await page.setContent( ` - + `, config ); @@ -174,12 +174,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co await page.setContent( ` - + `, config ); @@ -193,11 +193,11 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co await page.setContent( ` - + `, config ); diff --git a/core/src/components/textarea/test/custom/textarea.e2e.ts b/core/src/components/textarea/test/custom/textarea.e2e.ts new file mode 100644 index 00000000000..cdab1bd151d --- /dev/null +++ b/core/src/components/textarea/test/custom/textarea.e2e.ts @@ -0,0 +1,222 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * This behavior does not vary across modes/directions + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('textarea: custom'), () => { + test('should allow styling the container part', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const textarea = await page.locator('ion-textarea'); + const container = await textarea.evaluate((el: HTMLIonTextareaElement) => { + const containerEl = el.shadowRoot?.querySelector('[part="container"]') as HTMLElement | null; + if (!containerEl) { + return ''; + } + return getComputedStyle(containerEl).backgroundColor; + }); + + expect(container).toBe('rgb(0, 0, 255)'); + }); + + test('should allow styling the label part', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const textarea = await page.locator('ion-textarea'); + const labelColor = await textarea.evaluate((el: HTMLIonTextareaElement) => { + const labelEl = el.shadowRoot?.querySelector('[part="label"]') as HTMLElement | null; + if (!labelEl) { + return ''; + } + return getComputedStyle(labelEl).color; + }); + + expect(labelColor).toBe('rgb(0, 128, 0)'); + }); + + test('should allow styling the native textarea', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const textarea = await page.locator('ion-textarea'); + const color = await textarea.evaluate( + (el: HTMLIonTextareaElement) => + getComputedStyle(el.shadowRoot?.querySelector('textarea') as HTMLTextAreaElement).color + ); + + expect(color).toBe('rgb(255, 0, 0)'); + }); + + test('should allow styling the supporting-text part', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const textarea = await page.locator('ion-textarea'); + await textarea.waitFor(); + + const supportingTextColor = await textarea.evaluate((el: HTMLIonTextareaElement) => { + // Query for the visible helper-text element which has the supporting-text part + // Use attribute selector that matches space-separated part values + const helperTextEl = el.shadowRoot?.querySelector('[part~="helper-text"]') as HTMLElement | null; + if (!helperTextEl) { + return ''; + } + return getComputedStyle(helperTextEl).color; + }); + + expect(supportingTextColor).toBe('rgb(0, 0, 255)'); + }); + + test('should allow styling the helper-text part', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const textarea = await page.locator('ion-textarea'); + await textarea.waitFor(); + + const helperTextColor = await textarea.evaluate((el: HTMLIonTextareaElement) => { + const helperTextEl = el.shadowRoot?.querySelector('[part~="helper-text"]') as HTMLElement | null; + if (!helperTextEl) { + return ''; + } + return getComputedStyle(helperTextEl).color; + }); + + expect(helperTextColor).toBe('rgb(255, 0, 0)'); + }); + + test('should allow styling the error-text part', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const textarea = await page.locator('ion-textarea'); + await textarea.waitFor(); + + const errorTextColor = await textarea.evaluate((el: HTMLIonTextareaElement) => { + const errorTextEl = el.shadowRoot?.querySelector('[part~="error-text"]') as HTMLElement | null; + if (!errorTextEl) { + return ''; + } + return getComputedStyle(errorTextEl).color; + }); + + expect(errorTextColor).toBe('rgb(255, 0, 0)'); + }); + + test('should allow styling the counter part', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const textarea = await page.locator('ion-textarea'); + const counterColor = await textarea.evaluate((el: HTMLIonTextareaElement) => { + const counterEl = el.shadowRoot?.querySelector('[part="counter"]') as HTMLElement | null; + if (!counterEl) { + return ''; + } + return getComputedStyle(counterEl).color; + }); + + expect(counterColor).toBe('rgb(0, 128, 0)'); + }); + + test('should allow styling the bottom part', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const textarea = await page.locator('ion-textarea'); + const bottomBgColor = await textarea.evaluate((el: HTMLIonTextareaElement) => { + const bottomEl = el.shadowRoot?.querySelector('[part="bottom"]') as HTMLElement | null; + if (!bottomEl) { + return ''; + } + return getComputedStyle(bottomEl).backgroundColor; + }); + + expect(bottomBgColor).toBe('rgb(0, 0, 255)'); + }); + }); +}); diff --git a/core/src/components/textarea/test/form/index.html b/core/src/components/textarea/test/form/index.html new file mode 100644 index 00000000000..e64e6561aaf --- /dev/null +++ b/core/src/components/textarea/test/form/index.html @@ -0,0 +1,77 @@ + + + + + Textarea - Form + + + + + + + + + + + + + + + Textarea - Form + + + + +
+ +
Label (Required) *
+ +
+ +

+ Click the first button below to toggle the required prop and then click the submit button to attempt to + submit the form. It should show a popup warning to fill out the field when textarea is required and empty. + When the textarea is not required, or the field is filled, the form should submit by sending a console log. +

+ + +
+
+
+ + + + diff --git a/core/src/components/textarea/test/form/textarea.e2e.ts b/core/src/components/textarea/test/form/textarea.e2e.ts new file mode 100644 index 00000000000..72e4cee93b7 --- /dev/null +++ b/core/src/components/textarea/test/form/textarea.e2e.ts @@ -0,0 +1,139 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * This behavior does not vary across modes/directions + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('textarea: form'), () => { + test('should be marked as invalid when required and empty', async ({ page }) => { + await page.setContent( + ` +
+ + +
+ + `, + config + ); + + let formSubmitted = false; + + const textarea = page.locator('ion-textarea'); + const submitButton = page.locator('button[type="submit"]'); + + // Check that the textarea's browser validation is working before submission + const validationInfo = await textarea.evaluate((el: HTMLIonTextareaElement) => { + const nativeTextarea = el.shadowRoot?.querySelector('textarea') as HTMLTextAreaElement | null; + if (!nativeTextarea) { + return { isValid: false, willValidate: false, validationMessage: '', checkValidity: false }; + } + return { + isValid: nativeTextarea.validity.valid, + willValidate: nativeTextarea.willValidate, + validationMessage: nativeTextarea.validationMessage, + checkValidity: nativeTextarea.checkValidity(), + }; + }); + + expect(validationInfo.willValidate).toBe(true); + expect(validationInfo.isValid).toBe(false); + expect(validationInfo.checkValidity).toBe(false); + expect(validationInfo.validationMessage.length).toBeGreaterThan(0); + + // Click submit button - browser validation should prevent form submission + // and show the native validation popup + await submitButton.click(); + + // Wait for any async operations to complete + await page.waitForChanges(); + + // Check that form was not submitted (browser validation prevented it) + formSubmitted = await page.evaluate(() => (window as any).formSubmitted ?? false); + expect(formSubmitted).toBe(false); + + // Verify that the form's validation was triggered and it's invalid + const formValidity = await page.evaluate(() => { + const form = document.querySelector('form'); + return form ? form.checkValidity() : null; + }); + expect(formValidity).toBe(false); + + // Verify the textarea's validity is still false after submit attempt + const isValidAfterSubmit = await textarea.evaluate((el: HTMLIonTextareaElement) => { + const nativeTextarea = el.shadowRoot?.querySelector('textarea') as HTMLTextAreaElement | null; + return nativeTextarea?.validity.valid ?? false; + }); + expect(isValidAfterSubmit).toBe(false); + }); + + test('should be marked as valid when required and filled', async ({ page }) => { + await page.setContent( + ` +
+ + +
+ + `, + config + ); + + const textarea = page.locator('ion-textarea'); + const submitButton = page.locator('button[type="submit"]'); + + // Type into the native textarea in the shadow DOM + await textarea.evaluate((el: HTMLIonTextareaElement) => { + const nativeTextarea = el.shadowRoot?.querySelector('textarea') as HTMLTextAreaElement | null; + if (nativeTextarea) { + nativeTextarea.value = 'Test value'; + nativeTextarea.dispatchEvent(new Event('input', { bubbles: true })); + } + }); + + // Check that the textarea's browser validation is working before submission + const isValidBeforeSubmit = await textarea.evaluate((el: HTMLIonTextareaElement) => { + const nativeTextarea = el.shadowRoot?.querySelector('textarea') as HTMLTextAreaElement | null; + return nativeTextarea?.validity.valid ?? false; + }); + expect(isValidBeforeSubmit).toBe(true); + + // Click submit button - form should submit since validation passes + await submitButton.click(); + + // Wait for any async operations to complete + await page.waitForChanges(); + + // Check that form was submitted (validation passed) + const formSubmitted = await page.evaluate(() => (window as any).formSubmitted ?? false); + expect(formSubmitted).toBe(true); + + // Verify that the form's validation passed + const formValidity = await page.evaluate(() => { + const form = document.querySelector('form'); + return form ? form.checkValidity() : null; + }); + expect(formValidity).toBe(true); + + // Verify the textarea's validity is still true after submit + const isValidAfterSubmit = await textarea.evaluate((el: HTMLIonTextareaElement) => { + const nativeTextarea = el.shadowRoot?.querySelector('textarea') as HTMLTextAreaElement | null; + return nativeTextarea?.validity.valid ?? false; + }); + expect(isValidAfterSubmit).toBe(true); + }); + }); +}); diff --git a/core/src/components/textarea/test/textarea.spec.ts b/core/src/components/textarea/test/textarea.spec.ts index f1611a3e291..b0eeb949ba5 100644 --- a/core/src/components/textarea/test/textarea.spec.ts +++ b/core/src/components/textarea/test/textarea.spec.ts @@ -8,7 +8,8 @@ it('should inherit attributes', async () => { html: '', }); - const nativeEl = page.body.querySelector('ion-textarea textarea')!; + const textareaEl = page.body.querySelector('ion-textarea')!; + const nativeEl = textareaEl.shadowRoot!.querySelector('textarea')!; expect(nativeEl.getAttribute('title')).toBe('my title'); expect(nativeEl.getAttribute('tabindex')).toBe('-1'); expect(nativeEl.getAttribute('data-form-type')).toBe('password'); @@ -21,7 +22,7 @@ it('should inherit watched attributes', async () => { }); const textareaEl = page.body.querySelector('ion-textarea')!; - const nativeEl = textareaEl.querySelector('textarea')!; + const nativeEl = textareaEl.shadowRoot!.querySelector('textarea')!; expect(nativeEl.getAttribute('dir')).toBe('ltr'); @@ -52,7 +53,7 @@ describe('textarea: label rendering', () => { const textarea = page.body.querySelector('ion-textarea')!; - const labelText = textarea.querySelector('.label-text-wrapper')!; + const labelText = textarea.shadowRoot!.querySelector('.label-text-wrapper')!; expect(labelText.textContent).toBe('Label Prop Text'); }); @@ -66,9 +67,16 @@ describe('textarea: label rendering', () => { const textarea = page.body.querySelector('ion-textarea')!; - const labelText = textarea.querySelector('.label-text-wrapper')!; + // When using a slot, the content is in the light DOM, not directly + // accessible via textContent. Check that the slot element exists and + // the slotted content is in the light DOM. + const slotEl = textarea.shadowRoot!.querySelector('slot[name="label"]'); + const propEl = textarea.shadowRoot!.querySelector('.label-text'); + const slottedContent = textarea.querySelector('[slot="label"]'); - expect(labelText.textContent).toBe('Label Prop Slot'); + expect(slotEl).not.toBe(null); + expect(propEl).toBe(null); + expect(slottedContent?.textContent).toBe('Label Prop Slot'); }); it('should render label prop if both prop and slot provided', async () => { const page = await newSpecPage({ @@ -80,7 +88,7 @@ describe('textarea: label rendering', () => { const textarea = page.body.querySelector('ion-textarea')!; - const labelText = textarea.querySelector('.label-text-wrapper')!; + const labelText = textarea.shadowRoot!.querySelector('.label-text-wrapper')!; expect(labelText.textContent).toBe('Label Prop Text'); }); diff --git a/core/src/components/textarea/test/textarea.spec.tsx b/core/src/components/textarea/test/textarea.spec.tsx index e0ed6363f58..e7b0230756f 100644 --- a/core/src/components/textarea/test/textarea.spec.tsx +++ b/core/src/components/textarea/test/textarea.spec.tsx @@ -7,7 +7,8 @@ it('should render bottom content when helper text is defined', async () => { html: ``, }); - const bottomContent = page.body.querySelector('ion-textarea .textarea-bottom'); + const textarea = page.body.querySelector('ion-textarea')!; + const bottomContent = textarea.shadowRoot!.querySelector('.textarea-bottom'); expect(bottomContent).not.toBe(null); }); @@ -17,7 +18,8 @@ it('should render bottom content when helper text is undefined', async () => { html: ``, }); - const bottomContent = page.body.querySelector('ion-textarea .textarea-bottom'); + const textarea = page.body.querySelector('ion-textarea')!; + const bottomContent = textarea.shadowRoot!.querySelector('.textarea-bottom'); expect(bottomContent).toBe(null); }); @@ -27,6 +29,7 @@ it('should render bottom content when helper text is empty string', async () => html: ``, }); - const bottomContent = page.body.querySelector('ion-textarea .textarea-bottom'); + const textarea = page.body.querySelector('ion-textarea')!; + const bottomContent = textarea.shadowRoot!.querySelector('.textarea-bottom'); expect(bottomContent).toBe(null); }); diff --git a/core/src/components/textarea/textarea.common.scss b/core/src/components/textarea/textarea.common.scss index 0fbc42e1754..e25866fbe36 100644 --- a/core/src/components/textarea/textarea.common.scss +++ b/core/src/components/textarea/textarea.common.scss @@ -82,12 +82,12 @@ // Textarea Within An Item // -------------------------------------------------- -:host-context(ion-item) { +:host(.in-item) { align-self: baseline; } -:host-context(ion-item)[slot="start"], -:host-context(ion-item)[slot="end"] { +:host(.in-item[slot="start"]), +:host(.in-item[slot="end"]) { width: auto; } @@ -311,6 +311,8 @@ width: 100%; min-height: inherit; + + box-sizing: border-box; } // Textarea Highlight @@ -432,6 +434,10 @@ overflow: hidden; } +.textarea-outline { + box-sizing: border-box; +} + /** * If no label text is placed into the slot * then the element should be hidden otherwise diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index 272b21f3077..2c6ad74ad6b 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { + AttachInternals, Build, Component, Element, @@ -15,7 +16,7 @@ import { writeTask, } from '@stencil/core'; import type { NotchController } from '@utils/forms'; -import { createNotchController, checkInvalidState } from '@utils/forms'; +import { createNotchController, checkInvalidState, reportValidityToElementInternals } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, debounceEvent, inheritAttributes, componentOnReady } from '@utils/helpers'; import { createSlotMutationController } from '@utils/slot-mutation-controller'; @@ -35,6 +36,15 @@ import type { TextareaChangeEventDetail, TextareaInputEventDetail } from './text * @slot label - The label text to associate with the textarea. Use the `labelPlacement` property to control where the label is placed relative to the textarea. Use this if you need to render a label with custom HTML. (EXPERIMENTAL) * @slot start - Content to display at the leading edge of the textarea. (EXPERIMENTAL) * @slot end - Content to display at the trailing edge of the textarea. (EXPERIMENTAL) + * + * @part container - The wrapper element for the textarea. + * @part label - The label text describing the textarea. + * @part native - The native textarea element. + * @part supporting-text - Supporting text displayed beneath the textarea label. + * @part helper-text - Supporting text displayed beneath the textarea label when the textarea is valid. + * @part error-text - Supporting text displayed beneath the textarea label when the textarea is invalid and touched. + * @part counter - The character counter displayed when the counter property is set. + * @part bottom - The container element for helper text, error text, and counter. */ @Component({ tag: 'ion-textarea', @@ -43,7 +53,8 @@ import type { TextareaChangeEventDetail, TextareaInputEventDetail } from './text md: 'textarea.md.scss', ionic: 'textarea.ionic.scss', }, - scoped: true, + shadow: true, + formAssociated: true, }) export class Textarea implements ComponentInterface { private nativeInput?: HTMLTextAreaElement; @@ -73,6 +84,8 @@ export class Textarea implements ComponentInterface { @Element() el!: HTMLIonTextareaElement; + @AttachInternals() internals!: ElementInternals; + /** * The `hasFocus` state ensures the focus class is * added regardless of how the element is focused. @@ -136,6 +149,14 @@ export class Textarea implements ComponentInterface { */ @Prop() disabled = false; + /** + * Update element internals when disabled prop changes + */ + @Watch('disabled') + protected disabledChanged() { + this.updateElementInternals(); + } + /** * The fill for the item. If `"solid"` the item will have a background. If * `"outline"` the item will be transparent with a border. Only available when the theme is `"md"`. @@ -184,7 +205,7 @@ export class Textarea implements ComponentInterface { /** * If `true`, the user must fill in a value before submitting a form. */ - @Prop() required = false; + @Prop({ reflect: true }) required = false; /** * If `true`, the element will have its spelling and grammar checked. @@ -287,9 +308,25 @@ export class Textarea implements ComponentInterface { if (nativeInput && nativeInput.value !== value) { nativeInput.value = value; } + this.updateElementInternals(); this.runAutoGrow(); } + /** + * Update native input and element internals when required prop changes + */ + @Watch('required') + protected requiredChanged() { + // Explicitly update the native element's required attribute to ensure + // browser validation works correctly when required changes dynamically. + // While the template binding should handle this, we need to update it + // synchronously for the browser's validation to recognize the change. + if (this.nativeInput) { + this.nativeInput.required = this.required; + } + this.updateElementInternals(); + } + /** * dir is a globally enumerated attribute. * As a result, creating these as properties @@ -422,6 +459,7 @@ export class Textarea implements ComponentInterface { componentDidLoad() { this.originalIonInput = this.ionInput; + this.updateElementInternals(); this.runAutoGrow(); } @@ -544,6 +582,24 @@ export class Textarea implements ComponentInterface { return this.value || ''; } + /** + * Updates the form value and reports validity state to the browser via + * ElementInternals. This should be called when the component loads, when + * the required prop changes, when the disabled prop changes, and when the value + * changes to ensure the form value stays in sync and validation state is updated. + */ + private updateElementInternals() { + // Disabled form controls should not be included in form data + // Pass null to setFormValue when disabled to exclude it from form submission + const value = this.disabled ? null : this.getValue(); + // ElementInternals may not be fully available in test environments + // so we need to check if the method exists before calling it + if (typeof this.internals.setFormValue === 'function') { + this.internals.setFormValue(value); + } + reportValidityToElementInternals(this.nativeInput, this.internals); + } + // `Event` type is used instead of `InputEvent` // since the types from Stencil are not derived // from the element (e.g. textarea and input @@ -595,6 +651,7 @@ export class Textarea implements ComponentInterface { 'label-text-wrapper': true, 'label-text-wrapper-hidden': !this.hasLabel, }} + part="label" > {label === undefined ? :
{label}
} @@ -701,10 +758,10 @@ export class Textarea implements ComponentInterface { const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; return [ -
+
{!isInvalid ? helperText : null}
, -