From 54a5e4ecb379e42d53eb14d44af9d73013f16776 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:41:33 -0500 Subject: [PATCH 01/16] refactor(textarea): convert to a form associated shadow component --- core/src/components/textarea/textarea.tsx | 34 +++++++++++++++++++++-- core/src/utils/forms/index.ts | 1 + core/src/utils/forms/validity.ts | 34 +++++++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 core/src/utils/forms/validity.ts diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index 6a6d51f3c4b..b767439978f 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 } from '@utils/forms'; +import { createNotchController, 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'; @@ -43,7 +44,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 +75,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. @@ -184,7 +188,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. @@ -288,6 +292,15 @@ export class Textarea implements ComponentInterface { nativeInput.value = value; } this.runAutoGrow(); + this.reportValidity(); + } + + /** + * Update validation state when required prop changes + */ + @Watch('required') + protected requiredChanged() { + this.reportValidity(); } /** @@ -433,6 +446,7 @@ export class Textarea implements ComponentInterface { componentDidLoad() { this.originalIonInput = this.ionInput; this.runAutoGrow(); + this.reportValidity(); } componentDidRender() { @@ -554,6 +568,15 @@ export class Textarea implements ComponentInterface { return this.value || ''; } + /** + * Reports the validity state to the browser via ElementInternals. + * This delegates to the native textarea's built-in validation, + * which automatically handles the required prop and other constraints. + */ + private reportValidity() { + 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 @@ -568,6 +591,11 @@ export class Textarea implements ComponentInterface { }; private onChange = (ev: Event) => { + const input = ev.target as HTMLTextAreaElement | null; + if (input) { + this.internals.setFormValue(input.value); + this.reportValidity(); + } this.emitValueChange(ev); }; diff --git a/core/src/utils/forms/index.ts b/core/src/utils/forms/index.ts index d24bddfaa77..682811ed643 100644 --- a/core/src/utils/forms/index.ts +++ b/core/src/utils/forms/index.ts @@ -1,2 +1,3 @@ export * from './notch-controller'; export * from './compare-with-utils'; +export * from './validity'; diff --git a/core/src/utils/forms/validity.ts b/core/src/utils/forms/validity.ts new file mode 100644 index 00000000000..2e46595206a --- /dev/null +++ b/core/src/utils/forms/validity.ts @@ -0,0 +1,34 @@ +// TODO this file is going to cause conflicts once it updates from main + +export const getValidityFlags = (validity: ValidityState): ValidityStateFlags => { + return { + badInput: validity.badInput, + customError: validity.customError, + patternMismatch: validity.patternMismatch, + rangeOverflow: validity.rangeOverflow, + rangeUnderflow: validity.rangeUnderflow, + stepMismatch: validity.stepMismatch, + tooLong: validity.tooLong, + tooShort: validity.tooShort, + typeMismatch: validity.typeMismatch, + valueMissing: validity.valueMissing, + }; +}; + +/** + * Reports the validity state of a native form element to ElementInternals. + * This delegates to the native element's built-in validation, which automatically + * handles required, minlength, maxlength, and other constraints. + */ +export const reportValidityToElementInternals = (nativeElement: HTMLInputElement | HTMLTextAreaElement | null | undefined, internals: ElementInternals): void => { + if (!nativeElement) { + return; + } + + if (nativeElement.validity.valid) { + internals.setValidity({}); + } else { + const validityFlags = getValidityFlags(nativeElement.validity); + internals.setValidity(validityFlags, nativeElement.validationMessage, nativeElement); + } +}; From 84177416728835a6b9a0c60eda4c31167100b99c Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:05:30 -0500 Subject: [PATCH 02/16] style: lint --- core/src/components/textarea/textarea.tsx | 2 +- core/src/utils/forms/validity.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index b767439978f..81da17e28dc 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -45,7 +45,7 @@ import type { TextareaChangeEventDetail, TextareaInputEventDetail } from './text ionic: 'textarea.ionic.scss', }, shadow: true, - formAssociated: true + formAssociated: true, }) export class Textarea implements ComponentInterface { private nativeInput?: HTMLTextAreaElement; diff --git a/core/src/utils/forms/validity.ts b/core/src/utils/forms/validity.ts index 2e46595206a..a975b841746 100644 --- a/core/src/utils/forms/validity.ts +++ b/core/src/utils/forms/validity.ts @@ -20,7 +20,10 @@ export const getValidityFlags = (validity: ValidityState): ValidityStateFlags => * This delegates to the native element's built-in validation, which automatically * handles required, minlength, maxlength, and other constraints. */ -export const reportValidityToElementInternals = (nativeElement: HTMLInputElement | HTMLTextAreaElement | null | undefined, internals: ElementInternals): void => { +export const reportValidityToElementInternals = ( + nativeElement: HTMLInputElement | HTMLTextAreaElement | null | undefined, + internals: ElementInternals +): void => { if (!nativeElement) { return; } From 59b242370898922008bd37c0d3362eb47817d1cc Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:18:09 -0500 Subject: [PATCH 03/16] chore: build --- core/api.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/api.txt b/core/api.txt index e4597ef5934..f47e68f9fb1 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 From 08a90a2784a9ce2a587d3ce800d1df6ae7ed6449 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:21:31 -0500 Subject: [PATCH 04/16] test(textarea): query the shadowRoot in tests --- .../components/textarea/test/textarea.spec.ts | 20 +++++++++++++------ .../textarea/test/textarea.spec.tsx | 9 ++++++--- core/src/utils/forms/validity.ts | 2 +- 3 files changed, 21 insertions(+), 10 deletions(-) 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/utils/forms/validity.ts b/core/src/utils/forms/validity.ts index a975b841746..cd1d95564f4 100644 --- a/core/src/utils/forms/validity.ts +++ b/core/src/utils/forms/validity.ts @@ -24,7 +24,7 @@ export const reportValidityToElementInternals = ( nativeElement: HTMLInputElement | HTMLTextAreaElement | null | undefined, internals: ElementInternals ): void => { - if (!nativeElement) { + if (!nativeElement?.validity) { return; } From f7e81467f97b88c7c106a76e38ef102da8785aa9 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:12:40 -0500 Subject: [PATCH 05/16] test(angular): add textarea to lazy forms test --- .../test/base/e2e/src/lazy/form.spec.ts | 19 +++++++++++++++++++ .../src/app/lazy/form/form.component.html | 17 ++++++++++++++++- .../base/src/app/lazy/form/form.component.ts | 13 ++++++++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/angular/test/base/e2e/src/lazy/form.spec.ts b/packages/angular/test/base/e2e/src/lazy/form.spec.ts index a5cacabe5e2..ef364421325 100644 --- a/packages/angular/test/base/e2e/src/lazy/form.spec.ts +++ b/packages/angular/test/base/e2e/src/lazy/form.spec.ts @@ -31,6 +31,8 @@ test.describe('Form', () => { datetime: '2010-08-20', select: null, toggle: false, + textarea: '', + textarea2: 'Default Value', input: '', input2: 'Default Value', inputMin: 1, @@ -47,6 +49,9 @@ test.describe('Form', () => { await page.locator('ion-input.required input').fill('Some value'); await page.locator('ion-input.required input').blur(); + await page.locator('ion-textarea.required textarea').fill('Some value'); + await page.locator('ion-textarea.required textarea').blur(); + // Test number OTP input await page.locator('#touched-input-otp-number-test input').nth(0).fill('5'); await page.locator('#touched-input-otp-number-test input').nth(1).fill('6'); @@ -78,6 +83,8 @@ test.describe('Form', () => { datetime: '2010-08-20', select: 'nes', toggle: false, + textarea: 'Some value', + textarea2: 'Default Value', input: 'Some value', input2: 'Default Value', inputMin: 1, @@ -96,6 +103,8 @@ test.describe('Form', () => { datetime: '2010-08-20', select: null, toggle: true, + textarea: '', + textarea2: 'Default Value', input: '', input2: 'Default Value', inputMin: 1, @@ -114,6 +123,8 @@ test.describe('Form', () => { datetime: '2010-08-20', select: null, toggle: false, + textarea: '', + textarea2: 'Default Value', input: '', input2: 'Default Value', inputMin: 1, @@ -132,6 +143,8 @@ test.describe('Form', () => { datetime: '2010-08-20', select: null, toggle: false, + textarea: '', + textarea2: 'Default Value', input: '', input2: 'Default Value', inputMin: 1, @@ -165,6 +178,8 @@ test.describe('Form', () => { datetime: '2010-08-20', select: null, toggle: false, + textarea: '', + textarea2: 'Default Value', input: '', input2: 'Default Value', inputMin: 1, @@ -232,6 +247,8 @@ test.describe('Form', () => { datetime: '2010-08-20', select: null, toggle: true, + textarea: '', + textarea2: 'Default Value', input: '', input2: 'Default Value', inputMin: 1, @@ -247,6 +264,8 @@ test.describe('Form', () => { datetime: '2010-08-20', select: null, toggle: true, + textarea: '', + textarea2: 'Default Value', input: '', input2: 'Default Value', inputMin: 1, diff --git a/packages/angular/test/base/src/app/lazy/form/form.component.html b/packages/angular/test/base/src/app/lazy/form/form.component.html index f9244756da4..1067b6ec249 100644 --- a/packages/angular/test/base/src/app/lazy/form/form.component.html +++ b/packages/angular/test/base/src/app/lazy/form/form.component.html @@ -29,6 +29,21 @@ Toggle + + + + + Set Textarea Touched + + + + + - Set Input Touched + Set Input Touched diff --git a/packages/angular/test/base/src/app/lazy/form/form.component.ts b/packages/angular/test/base/src/app/lazy/form/form.component.ts index 5e670482b47..e04f7a6680f 100644 --- a/packages/angular/test/base/src/app/lazy/form/form.component.ts +++ b/packages/angular/test/base/src/app/lazy/form/form.component.ts @@ -26,6 +26,8 @@ export class FormComponent { datetime: ['2010-08-20', Validators.required], select: [undefined, Validators.required], toggle: [false], + textarea: ['', Validators.required], + textarea2: ['Default Value'], input: ['', Validators.required], input2: ['Default Value'], inputOtp: [null, [Validators.required, otpRequiredLength(4)]], @@ -40,13 +42,20 @@ export class FormComponent { }); } - setTouched() { + setInputTouched() { const formControl = this.profileForm.get('input'); if (formControl) { formControl.markAsTouched(); } } + setTextareaTouched() { + const formControl = this.profileForm.get('textarea'); + if (formControl) { + formControl.markAsTouched(); + } + } + setOtpTouched() { const formControl = this.profileForm.get('inputOtp'); if (formControl) { @@ -63,6 +72,8 @@ export class FormComponent { datetime: '2010-08-20', select: 'nes', toggle: true, + textarea: 'Some value', + textarea2: 'Another values', input: 'Some value', input2: 'Another values', inputOtp: 5678, From 6c7d3fb516418eba54a5c5d8736a535e40bff545 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:55:46 -0500 Subject: [PATCH 06/16] test(vue): query textarea by shadow and add validation tests --- packages/vue/test/base/src/views/Inputs.vue | 196 +++++++++--------- .../test/base/tests/e2e/specs/inputs.cy.js | 33 ++- 2 files changed, 132 insertions(+), 97 deletions(-) diff --git a/packages/vue/test/base/src/views/Inputs.vue b/packages/vue/test/base/src/views/Inputs.vue index 28108f4f0dd..5a2bc0175d7 100644 --- a/packages/vue/test/base/src/views/Inputs.vue +++ b/packages/vue/test/base/src/views/Inputs.vue @@ -38,11 +38,11 @@ - + - + @@ -50,7 +50,7 @@ - + @@ -99,7 +99,7 @@ - diff --git a/packages/vue/test/base/tests/e2e/specs/inputs.cy.js b/packages/vue/test/base/tests/e2e/specs/inputs.cy.js index 26c26f8ef95..ffe6a190564 100644 --- a/packages/vue/test/base/tests/e2e/specs/inputs.cy.js +++ b/packages/vue/test/base/tests/e2e/specs/inputs.cy.js @@ -63,9 +63,40 @@ describe('Inputs', () => { cy.get('#searchbar-ref').should('have.text', 'Hello Searchbar'); }); it('typing into textarea should update ref', () => { - cy.get('ion-textarea textarea').type('Hello Textarea', { scrollBehavior: false }); + cy.get('ion-textarea').shadow().find('textarea').type('Hello Textarea', { scrollBehavior: false }); cy.get('#textarea-ref').should('have.text', 'Hello Textarea'); }); }); + + describe('validation', () => { + it('should show invalid state for required inputs when empty and touched', () => { + cy.get('ion-input input').focus().blur(); + cy.get('ion-input').should('have.class', 'ion-invalid'); + + cy.get('ion-textarea').shadow().find('textarea').focus(); + cy.get('ion-textarea').blur(); + cy.get('ion-textarea').should('have.class', 'ion-invalid'); + + cy.get('ion-input-otp input').first().focus().blur(); + cy.get('ion-input-otp').should('have.class', 'ion-invalid'); + }); + + it('should show invalid state for required input-otp when partially filled', () => { + cy.get('ion-input-otp input').first().focus().blur(); + cy.get('ion-input-otp input').eq(0).type('12', { scrollBehavior: false }); + cy.get('ion-input-otp').should('have.class', 'ion-invalid'); + }); + + it('should show valid state for required inputs when filled', () => { + cy.get('ion-input input').type('Test value', { scrollBehavior: false }); + cy.get('ion-input').should('have.class', 'ion-valid'); + + cy.get('ion-textarea').shadow().find('textarea').type('Test value', { scrollBehavior: false }); + cy.get('ion-textarea').should('have.class', 'ion-valid'); + + cy.get('ion-input-otp input').eq(0).type('1234', { scrollBehavior: false }); + cy.get('ion-input-otp').should('have.class', 'ion-valid'); + }); + }); }) From 1fd0f2652538a23aac9a99d8c284f2555fb8e47b Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:17:58 -0500 Subject: [PATCH 07/16] test(react): query textarea by shadow and add validation tests --- packages/react/test/base/src/pages/Inputs.tsx | 213 +++++++++++------- .../tests/e2e/specs/components/inputs.cy.ts | 32 ++- 2 files changed, 163 insertions(+), 82 deletions(-) diff --git a/packages/react/test/base/src/pages/Inputs.tsx b/packages/react/test/base/src/pages/Inputs.tsx index 7e5db11b5de..d0bf605332b 100644 --- a/packages/react/test/base/src/pages/Inputs.tsx +++ b/packages/react/test/base/src/pages/Inputs.tsx @@ -66,6 +66,41 @@ const Inputs: React.FC = () => { const [segment, setSegment] = useState('dogs'); const [select, setSelect] = useState('apples'); + const [touched, setTouched] = useState({ + input: false, + inputOtp: false, + textarea: false, + }); + + const getValidationClasses = (fieldName: keyof typeof touched, value: string | number | null | undefined) => { + const isTouched = touched[fieldName]; + let isValid = false; + + // Handle ion-input-otp which has multiple inputs + if (fieldName === 'inputOtp') { + // input-otp needs to check if all inputs are filled + // (value length equals component length) + const valueStr = String(value || ''); + isValid = valueStr.length === 4; + } else { + const isEmpty = value === '' || value === null || value === undefined; + isValid = !isEmpty; + } + + // Always return validation classes + // ion-touched is only added on blur + const classes: string[] = []; + if (isTouched) { + classes.push('ion-touched'); + } + if (isValid) { + classes.push('ion-valid'); + } else { + classes.push('ion-invalid'); + } + return classes.join(' '); + }; + const reset = () => { setCheckbox(false); setToggle(false); @@ -78,6 +113,11 @@ const Inputs: React.FC = () => { setRadio('red'); setSegment('dogs'); setSelect('apples'); + setTouched({ + input: false, + inputOtp: false, + textarea: false, + }); }; const set = () => { @@ -133,96 +173,107 @@ const Inputs: React.FC = () => { - - ) => setCheckbox(e.detail.checked)} - > - Checkbox - - - - - ) => setToggle(e.detail.checked)} - > - Toggle - - - - - ) => setInput(e.detail.value!)} - label="Input" - > - - - - ) => setInputOtp(e.detail.value ?? '')} - > - - - - ) => setRange(e.detail.value as { lower: number; upper: number })} - > - - - - ) => setTextarea(e.detail.value!)} - label="Textarea" - > - - - - Datetime - ) => { - const value = e.detail.value; - if (typeof value === 'string') { - setDatetime(value); - } - }} - > - +
+ + ) => setCheckbox(e.detail.checked)} + > + Checkbox + + + + + ) => setToggle(e.detail.checked)} + > + Toggle + + + + + ) => setInput(e.detail.value!)} + onIonBlur={() => setTouched(prev => ({ ...prev, input: true }))} + className={getValidationClasses('input', input)} + label="Input" + required + > + + + + ) => setInputOtp(e.detail.value ?? '')} + onIonBlur={() => setTouched(prev => ({ ...prev, inputOtp: true }))} + className={getValidationClasses('inputOtp', inputOtp)} + required + > + - ) => setRadio(e.detail.value)} - > - Red + ) => setRange(e.detail.value as { lower: number; upper: number })} + > + - Green + ) => setTextarea(e.detail.value!)} + onIonBlur={() => setTouched(prev => ({ ...prev, textarea: true }))} + className={getValidationClasses('textarea', textarea)} + label="Textarea" + required + > + - Blue + Datetime + ) => { + const value = e.detail.value; + if (typeof value === 'string') { + setDatetime(value); + } + }} + > - - - >) => setSelect(e.detail.value)} - label="Select" + ) => setRadio(e.detail.value)} > - Apples - Bananas - - + + Red + + + Green + + + Blue + + + + + >) => setSelect(e.detail.value)} + label="Select" + > + Apples + Bananas + + +
Checkbox: {checkbox.toString()}
diff --git a/packages/react/test/base/tests/e2e/specs/components/inputs.cy.ts b/packages/react/test/base/tests/e2e/specs/components/inputs.cy.ts index b32468bd96e..58334b40797 100644 --- a/packages/react/test/base/tests/e2e/specs/components/inputs.cy.ts +++ b/packages/react/test/base/tests/e2e/specs/components/inputs.cy.ts @@ -67,9 +67,39 @@ describe('Inputs', () => { }); it('typing into textarea should update ref', () => { - cy.get('ion-textarea textarea').type('Hello Textarea', { scrollBehavior: false }); + cy.get('ion-textarea').shadow().find('textarea').type('Hello Textarea', { scrollBehavior: false }); cy.get('#textarea-ref').should('have.text', 'Hello Textarea'); }); }); + + describe('validation', () => { + it('should show invalid state for required inputs when empty and touched', () => { + cy.get('ion-input input').focus().blur(); + cy.get('ion-input').should('have.class', 'ion-invalid'); + + cy.get('ion-textarea').shadow().find('textarea').focus().blur(); + cy.get('ion-textarea').should('have.class', 'ion-invalid'); + + cy.get('ion-input-otp input').first().focus().blur(); + cy.get('ion-input-otp').should('have.class', 'ion-invalid'); + }); + + it('should show invalid state for required input-otp when partially filled', () => { + cy.get('ion-input-otp input').first().focus().blur(); + cy.get('ion-input-otp input').eq(0).type('12', { scrollBehavior: false }); + cy.get('ion-input-otp').should('have.class', 'ion-invalid'); + }); + + it('should show valid state for required inputs when filled', () => { + cy.get('ion-input input').type('Test value', { scrollBehavior: false }); + cy.get('ion-input').should('have.class', 'ion-valid'); + + cy.get('ion-textarea').shadow().find('textarea').type('Test value', { scrollBehavior: false }); + cy.get('ion-textarea').should('have.class', 'ion-valid'); + + cy.get('ion-input-otp input').eq(0).type('1234', { scrollBehavior: false }); + cy.get('ion-input-otp').should('have.class', 'ion-valid'); + }); + }); }) From 3bf92895c67ba90d21709293427f5e2d6760b9eb Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:06:14 -0500 Subject: [PATCH 08/16] test(frameworks): input-otp doesn't support required --- packages/react/test/base/src/pages/Inputs.tsx | 18 ++---------------- .../tests/e2e/specs/components/inputs.cy.ts | 12 ------------ packages/vue/test/base/src/views/Inputs.vue | 12 ++---------- .../vue/test/base/tests/e2e/specs/inputs.cy.js | 12 ------------ 4 files changed, 4 insertions(+), 50 deletions(-) diff --git a/packages/react/test/base/src/pages/Inputs.tsx b/packages/react/test/base/src/pages/Inputs.tsx index d0bf605332b..fb3972739d7 100644 --- a/packages/react/test/base/src/pages/Inputs.tsx +++ b/packages/react/test/base/src/pages/Inputs.tsx @@ -68,24 +68,14 @@ const Inputs: React.FC = () => { const [touched, setTouched] = useState({ input: false, - inputOtp: false, textarea: false, }); const getValidationClasses = (fieldName: keyof typeof touched, value: string | number | null | undefined) => { const isTouched = touched[fieldName]; - let isValid = false; - // Handle ion-input-otp which has multiple inputs - if (fieldName === 'inputOtp') { - // input-otp needs to check if all inputs are filled - // (value length equals component length) - const valueStr = String(value || ''); - isValid = valueStr.length === 4; - } else { - const isEmpty = value === '' || value === null || value === undefined; - isValid = !isEmpty; - } + const isEmpty = value === '' || value === null || value === undefined; + const isValid = !isEmpty; // Always return validation classes // ion-touched is only added on blur @@ -115,7 +105,6 @@ const Inputs: React.FC = () => { setSelect('apples'); setTouched({ input: false, - inputOtp: false, textarea: false, }); }; @@ -207,9 +196,6 @@ const Inputs: React.FC = () => { ) => setInputOtp(e.detail.value ?? '')} - onIonBlur={() => setTouched(prev => ({ ...prev, inputOtp: true }))} - className={getValidationClasses('inputOtp', inputOtp)} - required > diff --git a/packages/react/test/base/tests/e2e/specs/components/inputs.cy.ts b/packages/react/test/base/tests/e2e/specs/components/inputs.cy.ts index 58334b40797..fcce8995152 100644 --- a/packages/react/test/base/tests/e2e/specs/components/inputs.cy.ts +++ b/packages/react/test/base/tests/e2e/specs/components/inputs.cy.ts @@ -80,15 +80,6 @@ describe('Inputs', () => { cy.get('ion-textarea').shadow().find('textarea').focus().blur(); cy.get('ion-textarea').should('have.class', 'ion-invalid'); - - cy.get('ion-input-otp input').first().focus().blur(); - cy.get('ion-input-otp').should('have.class', 'ion-invalid'); - }); - - it('should show invalid state for required input-otp when partially filled', () => { - cy.get('ion-input-otp input').first().focus().blur(); - cy.get('ion-input-otp input').eq(0).type('12', { scrollBehavior: false }); - cy.get('ion-input-otp').should('have.class', 'ion-invalid'); }); it('should show valid state for required inputs when filled', () => { @@ -97,9 +88,6 @@ describe('Inputs', () => { cy.get('ion-textarea').shadow().find('textarea').type('Test value', { scrollBehavior: false }); cy.get('ion-textarea').should('have.class', 'ion-valid'); - - cy.get('ion-input-otp input').eq(0).type('1234', { scrollBehavior: false }); - cy.get('ion-input-otp').should('have.class', 'ion-valid'); }); }); }) diff --git a/packages/vue/test/base/src/views/Inputs.vue b/packages/vue/test/base/src/views/Inputs.vue index 5a2bc0175d7..03f6a9e3023 100644 --- a/packages/vue/test/base/src/views/Inputs.vue +++ b/packages/vue/test/base/src/views/Inputs.vue @@ -42,7 +42,7 @@ - + @@ -181,16 +181,8 @@ const setIonicClasses = (element: HTMLElement, isBlurEvent: boolean) => { requestAnimationFrame(() => { let isValid = false; - // Handle ion-input-otp which has multiple inputs - if (element.tagName === 'ION-INPUT-OTP') { - const ionInputOtp = element as any; - const value = ionInputOtp.value || ''; - const length = ionInputOtp.length || 4; - // input-otp needs to check if all inputs are filled - // (value length equals component length) - isValid = value.length === length; // Handle ion-textarea which uses shadow DOM - } else if (element.tagName === 'ION-TEXTAREA') { + if (element.tagName === 'ION-TEXTAREA') { const nativeTextarea = element.shadowRoot?.querySelector('textarea') as HTMLTextAreaElement | null; if (nativeTextarea) { isValid = nativeTextarea.checkValidity(); diff --git a/packages/vue/test/base/tests/e2e/specs/inputs.cy.js b/packages/vue/test/base/tests/e2e/specs/inputs.cy.js index ffe6a190564..91d33d0c8b2 100644 --- a/packages/vue/test/base/tests/e2e/specs/inputs.cy.js +++ b/packages/vue/test/base/tests/e2e/specs/inputs.cy.js @@ -77,15 +77,6 @@ describe('Inputs', () => { cy.get('ion-textarea').shadow().find('textarea').focus(); cy.get('ion-textarea').blur(); cy.get('ion-textarea').should('have.class', 'ion-invalid'); - - cy.get('ion-input-otp input').first().focus().blur(); - cy.get('ion-input-otp').should('have.class', 'ion-invalid'); - }); - - it('should show invalid state for required input-otp when partially filled', () => { - cy.get('ion-input-otp input').first().focus().blur(); - cy.get('ion-input-otp input').eq(0).type('12', { scrollBehavior: false }); - cy.get('ion-input-otp').should('have.class', 'ion-invalid'); }); it('should show valid state for required inputs when filled', () => { @@ -94,9 +85,6 @@ describe('Inputs', () => { cy.get('ion-textarea').shadow().find('textarea').type('Test value', { scrollBehavior: false }); cy.get('ion-textarea').should('have.class', 'ion-valid'); - - cy.get('ion-input-otp input').eq(0).type('1234', { scrollBehavior: false }); - cy.get('ion-input-otp').should('have.class', 'ion-valid'); }); }); }) From 27c18c21aeb81be6c8e44a253e9abc802c168fdc Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:39:29 -0500 Subject: [PATCH 09/16] test(react): blur focused element instead of targeting textarea --- .../test/base/tests/e2e/specs/components/inputs.cy.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react/test/base/tests/e2e/specs/components/inputs.cy.ts b/packages/react/test/base/tests/e2e/specs/components/inputs.cy.ts index fcce8995152..c0a757e8f03 100644 --- a/packages/react/test/base/tests/e2e/specs/components/inputs.cy.ts +++ b/packages/react/test/base/tests/e2e/specs/components/inputs.cy.ts @@ -78,7 +78,13 @@ describe('Inputs', () => { cy.get('ion-input input').focus().blur(); cy.get('ion-input').should('have.class', 'ion-invalid'); - cy.get('ion-textarea').shadow().find('textarea').focus().blur(); + // Use cy.focused().blur() instead of directly targeting ion-textarea or + // the native textarea element because: + // React 17/18 moves focus to the ion-textarea component + // React 19 moves focus to the native textarea element + // cy.focused() adapts to whichever element is actually focused + cy.get('ion-textarea').shadow().find('textarea').focus(); + cy.focused().blur(); cy.get('ion-textarea').should('have.class', 'ion-invalid'); }); From cd58d35c672218557c950e2ea4d8a50234da8ed6 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:55:37 -0500 Subject: [PATCH 10/16] feat(textarea): expose shadow parts --- core/api.txt | 8 ++++++++ core/src/components/textarea/textarea.tsx | 25 ++++++++++++++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/core/api.txt b/core/api.txt index f47e68f9fb1..57d77a242c0 100644 --- a/core/api.txt +++ b/core/api.txt @@ -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/textarea.tsx b/core/src/components/textarea/textarea.tsx index 81da17e28dc..1665f1849b5 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -36,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', @@ -633,6 +642,7 @@ export class Textarea implements ComponentInterface { 'label-text-wrapper': true, 'label-text-wrapper-hidden': !this.hasLabel, }} + part="label" > {label === undefined ? :
{label}
}
@@ -739,10 +749,10 @@ export class Textarea implements ComponentInterface { const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; return [ -
+
{!isInvalid ? helperText : null}
, -