diff --git a/BREAKING.md b/BREAKING.md
index 4dde0c88e2b..2bbb979de6a 100644
--- a/BREAKING.md
+++ b/BREAKING.md
@@ -19,6 +19,8 @@ This is a comprehensive list of the breaking changes introduced in the major ver
- [Card](#version-9x-card)
- [Chip](#version-9x-chip)
- [Grid](#version-9x-grid)
+ - [Radio Group](#version-9x-radio-group)
+ - [Textarea](#version-9x-textarea)
Components
@@ -38,15 +40,10 @@ This is a comprehensive list of the breaking changes introduced in the major ver
- The properties `pull` and `push` have been deprecated and no longer work. A similar look can be achieved with the newly added property `order`.
-
Radio Group
-
-- Converted `ion-radio-group` to use [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM).
-If you were targeting the internals of `ion-radio-group` in your CSS, you will need to target the `supporting-text`, `helper-text` or `error-text` [Shadow Parts](https://ionicframework.com/docs/theming/css-shadow-parts) instead, or use the provided CSS Variables.
-Additionally, the `radio-group-wrapper` div element has been removed, causing slotted elements to be direct children of the `ion-radio-group`.
-
Example 1: Swap two columns
**Version up to 8.x**
+
```html
@@ -57,6 +54,7 @@ Additionally, the `radio-group-wrapper` div element has been removed, causing sl
```
**Version 9.x+**
+
```html
@@ -68,9 +66,11 @@ Additionally, the `radio-group-wrapper` div element has been removed, causing sl
```
Example 2: Reorder columns with specific sizes
+
To reorder two columns where column 1 has `size="9" push="3"` and column 2 has `size="3" pull="9"`:
**Version up to 8.x**
+
```html
@@ -79,7 +79,9 @@ To reorder two columns where column 1 has `size="9" push="3"` and column 2 has `
```
+
**Version 9.x+**
+
```html
@@ -88,7 +90,9 @@ To reorder two columns where column 1 has `size="9" push="3"` and column 2 has `
```
+
Example 3: Push
+
```html
@@ -102,6 +106,7 @@ To reorder two columns where column 1 has `size="9" push="3"` and column 2 has `
```
**Version 9.x+**
+
```html
@@ -116,6 +121,7 @@ To reorder two columns where column 1 has `size="9" push="3"` and column 2 has `
```
Example 4: Push and Pull
+
```html
@@ -128,6 +134,7 @@ To reorder two columns where column 1 has `size="9" push="3"` and column 2 has `
```
+
**Version 9.x+**
```html
@@ -140,4 +147,18 @@ To reorder two columns where column 1 has `size="9" push="3"` and column 2 has `
-```
\ No newline at end of file
+```
+
+
Radio Group
+
+Converted `ion-radio-group` to use [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM).
+
+If you were targeting the internals of `ion-radio-group` in your CSS, you will need to target the `supporting-text`, `helper-text` or `error-text` [Shadow Parts](https://ionicframework.com/docs/theming/css-shadow-parts) instead, or use the provided CSS Variables.
+
+Additionally, the `radio-group-wrapper` div element has been removed, causing slotted elements to be direct children of the `ion-radio-group`.
+
+
Textarea
+
+Converted `ion-textarea` to use [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM).
+
+If you were targeting the internals of `ion-textarea` in your CSS, you will need to target the `container`, `label`, `native`, `supporting-text`, `helper-text`, `error-text`, `counter`, or `bottom` [Shadow Parts](https://ionicframework.com/docs/theming/css-shadow-parts) instead, or use the provided CSS Variables.
diff --git a/core/api.txt b/core/api.txt
index 40013c461ec..fa4a86fc8a4 100644
--- a/core/api.txt
+++ b/core/api.txt
@@ -2426,7 +2426,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
@@ -2450,7 +2450,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
@@ -2518,6 +2518,15 @@ 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-textarea,part,wrapper
ion-thumbnail,shadow
ion-thumbnail,prop,mode,"ios" | "md",undefined,false,false
diff --git a/core/src/components/popover/test/basic/index.html b/core/src/components/popover/test/basic/index.html
index 8d10eceb5c9..dce0a242e0a 100644
--- a/core/src/components/popover/test/basic/index.html
+++ b/core/src/components/popover/test/basic/index.html
@@ -76,6 +76,20 @@
>
Popover With Textarea
+
+
-
+
`,
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..7843bb05710
--- /dev/null
+++ b/core/src/components/textarea/test/form/index.html
@@ -0,0 +1,126 @@
+
+
+
+
+ Textarea - Form
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Textarea - Form
+
+
+
+
+
+
+
+
+
+
+
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..e9c1256a743
--- /dev/null
+++ b/core/src/components/textarea/test/form/textarea.e2e.ts
@@ -0,0 +1,282 @@
+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
+ 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);
+ });
+
+ test('should set formData when submit button is clicked', async ({ page }) => {
+ await page.setContent(
+ `
+
+
+ `,
+ config
+ );
+
+ const textarea = page.locator('ion-textarea');
+ const submitButton = page.locator('button[type="submit"]');
+
+ // Type into the native textarea
+ 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 }));
+ }
+ });
+
+ // Click submit button - form should submit since validation passes
+ await submitButton.click();
+
+ // Wait for any async operations to complete
+ await page.waitForChanges();
+
+ // 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 that the formData is set
+ const formData = await page.evaluate(() => {
+ const form = document.querySelector('form');
+ if (!form) {
+ return null;
+ }
+ const formData = new FormData(form);
+ const entries: Record = {};
+ for (const [key, value] of formData.entries()) {
+ entries[key] = value.toString();
+ }
+ return entries;
+ });
+ expect(formData).toBeDefined();
+ expect(formData?.['textarea']).toBe('Test value');
+ });
+
+ test('should reset formData when reset button is clicked', async ({ page }) => {
+ await page.setContent(
+ `
+
+
+ `,
+ config
+ );
+
+ const textarea = page.locator('ion-textarea');
+ const submitButton = page.locator('button[type="submit"]');
+ const resetButton = page.locator('button[type="reset"]');
+
+ // Type into the native textarea
+ 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 }));
+ }
+ });
+
+ // Click submit button - form should submit since validation passes
+ await submitButton.click();
+
+ // Wait for any async operations to complete
+ await page.waitForChanges();
+
+ // Verify that the formData is set
+ let formData = await page.evaluate(() => {
+ const form = document.querySelector('form');
+ if (!form) {
+ return null;
+ }
+ const formData = new FormData(form);
+ const entries: Record = {};
+ for (const [key, value] of formData.entries()) {
+ entries[key] = value.toString();
+ }
+ return entries;
+ });
+ expect(formData).toBeDefined();
+ expect(formData?.['textarea']).toBe('Test value');
+
+ // Click reset button - form should reset
+ await resetButton.click();
+
+ // Wait for any async operations to complete
+ await page.waitForChanges();
+
+ // Verify that the textarea's value is cleared
+ const textareaValue = await textarea.evaluate((el: HTMLIonTextareaElement) => {
+ const nativeTextarea = el.shadowRoot?.querySelector('textarea') as HTMLTextAreaElement | null;
+ return nativeTextarea?.value ?? '';
+ });
+ expect(textareaValue).toBe('');
+
+ // Verify that the formData is cleared
+ formData = await page.evaluate(() => {
+ const form = document.querySelector('form');
+ if (!form) {
+ return null;
+ }
+ const formData = new FormData(form);
+ const entries: Record = {};
+ for (const [key, value] of formData.entries()) {
+ entries[key] = value.toString();
+ }
+ return entries;
+ });
+ expect(formData?.['textarea']).toBe('');
+ });
+ });
+});
diff --git a/core/src/components/textarea/test/label-placement/textarea.e2e.ts b/core/src/components/textarea/test/label-placement/textarea.e2e.ts
index 60015b7f05f..3d07e0497cc 100644
--- a/core/src/components/textarea/test/label-placement/textarea.e2e.ts
+++ b/core/src/components/textarea/test/label-placement/textarea.e2e.ts
@@ -307,7 +307,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
await page.setContent(
`
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.md.outline.scss b/core/src/components/textarea/textarea.md.outline.scss
index 5cb2c66f348..8a81d6250c2 100644
--- a/core/src/components/textarea/textarea.md.outline.scss
+++ b/core/src/components/textarea/textarea.md.outline.scss
@@ -150,6 +150,8 @@
:host(.textarea-fill-outline) .textarea-outline-end {
border-top: var(--border-width) var(--border-style) var(--border-color);
border-bottom: var(--border-width) var(--border-style) var(--border-color);
+
+ box-sizing: border-box;
}
/**
diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx
index 272b21f3077..1790c49877a 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,16 @@ 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 wrapper - The clickable label element that wraps the entire form field (label text, slots, and native textarea).
+ * @part container - The inner wrapper element that directly contains the native textarea element.
+ * @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 +54,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 +85,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 +150,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 +206,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 +309,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,7 +460,15 @@ export class Textarea implements ComponentInterface {
componentDidLoad() {
this.originalIonInput = this.ionInput;
+ this.updateElementInternals();
this.runAutoGrow();
+
+ // Override focus() to delegate to the native textarea.
+ // This is needed for Safari which doesn't properly delegate
+ // focus when calling focus() directly on the host.
+ this.el.focus = () => {
+ this.setFocus();
+ };
}
componentDidRender() {
@@ -544,6 +590,32 @@ export class Textarea implements ComponentInterface {
return this.value || '';
}
+ /**
+ * Called when the form is reset.
+ * Resets the component's value.
+ */
+ formResetCallback() {
+ 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 +667,7 @@ export class Textarea implements ComponentInterface {
'label-text-wrapper': true,
'label-text-wrapper-hidden': !this.hasLabel,
}}
+ part="label"
>
{label === undefined ? :
@@ -802,6 +879,7 @@ export class Textarea implements ComponentInterface {
[`textarea-shape-${shape}`]: shape !== undefined,
[`textarea-size-${size}`]: true,
[`textarea-label-placement-${labelPlacement}`]: true,
+ 'in-item': inItem,
'textarea-disabled': disabled,
'textarea-readonly': readonly,
})}
@@ -812,7 +890,7 @@ export class Textarea implements ComponentInterface {
* interactable, clicking the label would focus that instead
* since it comes before the textarea in the DOM.
*/}
-