From 99573762f367d20b04261dcaa6f53b3847bf0571 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:22:22 -0500 Subject: [PATCH 1/6] feat(input-otp): convert to shadow --- BREAKING.md | 7 +++++ core/api.txt | 9 ++++++- core/src/components.d.ts | 16 +++++++++++ .../input-otp/input-otp.common.scss | 3 +++ .../input-otp/input-otp.native.scss | 2 ++ core/src/components/input-otp/input-otp.tsx | 27 ++++++++++++++++--- packages/angular/src/directives/proxies.ts | 4 +-- 7 files changed, 61 insertions(+), 7 deletions(-) diff --git a/BREAKING.md b/BREAKING.md index 2bbb979de6a..1b138cc012b 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -19,6 +19,7 @@ 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) + - [Input Otp](#version-9x-input-otp) - [Radio Group](#version-9x-radio-group) - [Textarea](#version-9x-textarea) @@ -149,6 +150,12 @@ To reorder two columns where column 1 has `size="9" push="3"` and column 2 has ` ``` +

Input Otp

+ +Converted `ion-input-otp` 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-input-otp` in your CSS, you will need to target the `group`, `container`, `native`, `separator` or `description` [Shadow Parts](https://ionicframework.com/docs/theming/css-shadow-parts) instead, or use the provided CSS Variables. +

Radio Group

Converted `ion-radio-group` to use [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM). diff --git a/core/api.txt b/core/api.txt index dd7f9195e18..b82f0a090b2 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1035,18 +1035,20 @@ ion-input,css-prop,--placeholder-opacity,ionic ion-input,css-prop,--placeholder-opacity,ios ion-input,css-prop,--placeholder-opacity,md -ion-input-otp,scoped +ion-input-otp,shadow ion-input-otp,prop,autocapitalize,string,'off',false,false ion-input-otp,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record | undefined,undefined,false,true ion-input-otp,prop,disabled,boolean,false,false,true ion-input-otp,prop,fill,"outline" | "solid" | undefined,'outline',false,false ion-input-otp,prop,inputmode,"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined,undefined,false,false ion-input-otp,prop,length,number,4,false,false +ion-input-otp,prop,mode,"ios" | "md",undefined,false,false ion-input-otp,prop,pattern,string | undefined,undefined,false,false ion-input-otp,prop,readonly,boolean,false,false,true ion-input-otp,prop,separators,number[] | string | undefined,undefined,false,false ion-input-otp,prop,shape,"rectangular" | "round" | "soft",'round',false,false ion-input-otp,prop,size,"large" | "medium" | "small",'medium',false,false +ion-input-otp,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-input-otp,prop,type,"number" | "text",'number',false,false ion-input-otp,prop,value,null | number | string | undefined,'',false,false ion-input-otp,method,setFocus,setFocus(index?: number) => Promise @@ -1127,6 +1129,11 @@ ion-input-otp,css-prop,--separator-width,md ion-input-otp,css-prop,--width,ionic ion-input-otp,css-prop,--width,ios ion-input-otp,css-prop,--width,md +ion-input-otp,part,container +ion-input-otp,part,description +ion-input-otp,part,group +ion-input-otp,part,native +ion-input-otp,part,separator ion-input-password-toggle,shadow ion-input-password-toggle,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record | undefined,undefined,false,true diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 5119829a658..a4f8833d199 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1779,6 +1779,10 @@ export namespace Components { * @default 4 */ "length": number; + /** + * The mode determines the platform behaviors of the component. + */ + "mode"?: "ios" | "md"; /** * A regex pattern string for allowed characters. Defaults based on type. For numbers (`type="number"`): `"[\p{N}]"` For text (`type="text"`): `"[\p{L}\p{N}]"` */ @@ -1807,6 +1811,10 @@ export namespace Components { * @default 'medium' */ "size": 'small' | 'medium' | 'large'; + /** + * The theme determines the visual appearance of the component. + */ + "theme"?: "ios" | "md" | "ionic"; /** * The type of input allowed in the input boxes. * @default 'number' @@ -7762,6 +7770,10 @@ declare namespace LocalJSX { * @default 4 */ "length"?: number; + /** + * The mode determines the platform behaviors of the component. + */ + "mode"?: "ios" | "md"; /** * Emitted when the input group loses focus. */ @@ -7805,6 +7817,10 @@ declare namespace LocalJSX { * @default 'medium' */ "size"?: 'small' | 'medium' | 'large'; + /** + * The theme determines the visual appearance of the component. + */ + "theme"?: "ios" | "md" | "ionic"; /** * The type of input allowed in the input boxes. * @default 'number' diff --git a/core/src/components/input-otp/input-otp.common.scss b/core/src/components/input-otp/input-otp.common.scss index 6af7c8cee63..74ae4ea2821 100644 --- a/core/src/components/input-otp/input-otp.common.scss +++ b/core/src/components/input-otp/input-otp.common.scss @@ -94,10 +94,13 @@ background: var(--background); color: var(--color); + font-family: inherit; font-size: inherit; text-align: center; appearance: none; + + box-sizing: border-box; } :host(.has-focus) .native-input { diff --git a/core/src/components/input-otp/input-otp.native.scss b/core/src/components/input-otp/input-otp.native.scss index daf2b3ff877..f2fe5b5ec89 100644 --- a/core/src/components/input-otp/input-otp.native.scss +++ b/core/src/components/input-otp/input-otp.native.scss @@ -19,6 +19,8 @@ --highlight-color-valid: #{ion-color(success, base)}; --highlight-color-invalid: #{ion-color(danger, base)}; + font-family: $font-family-base; + font-size: dynamic-font(14px); } diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx index 3e8684505ab..20c0471d9e7 100644 --- a/core/src/components/input-otp/input-otp.tsx +++ b/core/src/components/input-otp/input-otp.tsx @@ -16,6 +16,16 @@ import type { InputOtpInputEventDetail, } from './input-otp-interface'; +/** + * @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component. + * @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component. + * + * @part group - The container element that wraps all input boxes. + * @part container - The wrapper element for each individual input box. + * @part native - The native input element. + * @part separator - The separator element displayed between input boxes. + * @part description - The container element for the description text. + */ @Component({ tag: 'ion-input-otp', styleUrls: { @@ -23,7 +33,8 @@ import type { md: 'input-otp.md.scss', ionic: 'input-otp.ionic.scss', }, - scoped: true, + shadow: true, + formAssociated: true, }) export class InputOTP implements ComponentInterface { private inheritedAttributes: Attributes = {}; @@ -817,12 +828,19 @@ export class InputOTP implements ComponentInterface { 'input-otp-readonly': readonly, })} > -
+
{Array.from({ length }).map((_, index) => ( <> -
+
- {this.showSeparator(index) &&
} + {this.showSeparator(index) &&
} ))}
@@ -851,6 +869,7 @@ export class InputOTP implements ComponentInterface { 'input-otp-description': true, 'input-otp-description-hidden': !hasDescription, }} + part="description" >
diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index 91f6426be13..020f0d15c3d 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -1043,7 +1043,7 @@ This event will not emit when programmatically setting the `value` property. @ProxyCmp({ - inputs: ['autocapitalize', 'color', 'disabled', 'fill', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'], + inputs: ['autocapitalize', 'color', 'disabled', 'fill', 'inputmode', 'length', 'mode', 'pattern', 'readonly', 'separators', 'shape', 'size', 'theme', 'type', 'value'], methods: ['setFocus'] }) @Component({ @@ -1051,7 +1051,7 @@ This event will not emit when programmatically setting the `value` property. changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['autocapitalize', 'color', 'disabled', 'fill', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'], + inputs: ['autocapitalize', 'color', 'disabled', 'fill', 'inputmode', 'length', 'mode', 'pattern', 'readonly', 'separators', 'shape', 'size', 'theme', 'type', 'value'], }) export class IonInputOtp { protected el: HTMLIonInputOtpElement; From d316fc30ad6f720ea097322e98eabed9349f4e5a Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:53:05 -0500 Subject: [PATCH 2/6] test(react, vue): fix input reference --- .../react/test/base/tests/e2e/specs/components/inputs.cy.ts | 2 +- packages/vue/test/base/tests/e2e/specs/inputs.cy.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 c0a757e8f03..48ea4aa7964 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 @@ -55,7 +55,7 @@ describe('Inputs', () => { }); it('typing into input-otp should update ref', () => { - cy.get('ion-input-otp input').eq(0).type('1234', { scrollBehavior: false }); + cy.get('ion-input-otp').shadow().find('input').eq(0).type('1234', { scrollBehavior: false }); cy.get('#input-otp-ref').should('have.text', '1234'); }); 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 91d33d0c8b2..fc6f91cb6e2 100644 --- a/packages/vue/test/base/tests/e2e/specs/inputs.cy.js +++ b/packages/vue/test/base/tests/e2e/specs/inputs.cy.js @@ -53,7 +53,7 @@ describe('Inputs', () => { cy.get('#input-ref').should('have.text', 'Hello Input'); }); it('typing into input-otp should update ref', () => { - cy.get('ion-input-otp input').eq(0).type('1234', { scrollBehavior: false }); + cy.get('ion-input-otp').shadow().find('input').eq(0).type('1234', { scrollBehavior: false }); cy.get('#input-otp-ref').should('have.text', '1234'); }); From d73311abcf1b769cac0ec6c4d6319aa807c7e0f0 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:25:55 -0500 Subject: [PATCH 3/6] test(input-otp): add test for customizing shadow parts --- .../input-otp/test/custom/input-otp.e2e.ts | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 core/src/components/input-otp/test/custom/input-otp.e2e.ts diff --git a/core/src/components/input-otp/test/custom/input-otp.e2e.ts b/core/src/components/input-otp/test/custom/input-otp.e2e.ts new file mode 100644 index 00000000000..fda8263c3b0 --- /dev/null +++ b/core/src/components/input-otp/test/custom/input-otp.e2e.ts @@ -0,0 +1,163 @@ +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('input-otp: custom'), () => { + test('should allow styling the group part', async ({ page }) => { + await page.setContent( + ` + + + Description + `, + config + ); + + const inputOtp = await page.locator('ion-input-otp'); + const group = await inputOtp.evaluate((el: HTMLIonInputOtpElement) => { + const groupEl = el.shadowRoot?.querySelector('[part="group"]') as HTMLElement | null; + if (!groupEl) { + return ''; + } + return getComputedStyle(groupEl).backgroundColor; + }); + + expect(group).toBe('rgb(255, 0, 0)'); + }); + + test('should allow styling the container part', async ({ page }) => { + await page.setContent( + ` + + + Description + `, + config + ); + + const inputOtp = await page.locator('ion-input-otp'); + const container = await inputOtp.evaluate((el: HTMLIonInputOtpElement) => { + const containerEl = el.shadowRoot?.querySelector('[part="container"]') as HTMLElement | null; + if (!containerEl) { + return ''; + } + return getComputedStyle(containerEl).backgroundColor; + }); + + expect(container).toBe('rgb(0, 128, 0)'); + }); + + test('should allow styling the native part', async ({ page }) => { + await page.setContent( + ` + + + Description + `, + config + ); + + const inputOtp = await page.locator('ion-input-otp'); + + // Focus the first native input and then find the inactive and + // active native elements to verify the correct styles are applied + const { inactiveNative, activeNative } = await inputOtp.evaluate((el: HTMLIonInputOtpElement) => { + const nativeElements = el.shadowRoot?.querySelectorAll('[part="native"]') as + | NodeListOf + | undefined; + + if (!nativeElements || nativeElements.length === 0) { + return { inactiveNative: '', activeNative: '' }; + } + + // Focus the first native input + const firstNative = nativeElements[0] as HTMLInputElement; + firstNative.focus(); + + // Find the focused element. If the focused element is not + // a native input, use the first native input. + const activeNativeEl = + Array.from(nativeElements).find((nativeEl) => nativeEl === document.activeElement) || firstNative; + + // Find the first non-focused element + const inactiveNativeEl = Array.from(nativeElements).find((nativeEl) => nativeEl !== activeNativeEl); + + return { + inactiveNative: inactiveNativeEl ? getComputedStyle(inactiveNativeEl).backgroundColor : '', + activeNative: getComputedStyle(activeNativeEl).backgroundColor, + }; + }); + + expect(inactiveNative).toBe('rgb(0, 0, 255)'); + expect(activeNative).toBe('rgb(255, 0, 0)'); + }); + + test('should allow styling the separator part', async ({ page }) => { + await page.setContent( + ` + + + Description + `, + config + ); + + const inputOtp = await page.locator('ion-input-otp'); + const separator = await inputOtp.evaluate( + (el: HTMLIonInputOtpElement) => + getComputedStyle(el.shadowRoot?.querySelector('[part="separator"]') as HTMLElement).backgroundColor + ); + + expect(separator).toBe('rgb(0, 128, 0)'); + }); + + test('should allow styling the description part', async ({ page }) => { + await page.setContent( + ` + + + Description + `, + config + ); + + const inputOtp = await page.locator('ion-input-otp'); + const description = await inputOtp.evaluate((el: HTMLIonInputOtpElement) => { + const descriptionEl = el.shadowRoot?.querySelector('[part="description"]') as HTMLElement | null; + if (!descriptionEl) { + return ''; + } + return getComputedStyle(descriptionEl).color; + }); + + expect(description).toBe('rgb(0, 0, 255)'); + }); + }); +}); From a67b85afa2ae9b3b4bdf8710f9727b318a81019d Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:32:02 -0500 Subject: [PATCH 4/6] test(input-otp): add forms test --- .../components/input-otp/test/form/index.html | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 core/src/components/input-otp/test/form/index.html diff --git a/core/src/components/input-otp/test/form/index.html b/core/src/components/input-otp/test/form/index.html new file mode 100644 index 00000000000..7399a6f35c0 --- /dev/null +++ b/core/src/components/input-otp/test/form/index.html @@ -0,0 +1,159 @@ + + + + + Input OTP - Form + + + + + + + + + + + + + + + Input OTP - Form + + + + +
+ + Submit + Reset +
+ +
+

 

+

OTP Value:

+

Form Data:

+

+        
+
+ + +
+ + From 8123b371c18e97e9150cb915d3803ac906f7f85d Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:33:49 -0500 Subject: [PATCH 5/6] feat(input-otp): implement updateElementInternals for forms --- core/src/components/input-otp/input-otp.tsx | 51 ++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx index 20c0471d9e7..fc6ae05317d 100644 --- a/core/src/components/input-otp/input-otp.tsx +++ b/core/src/components/input-otp/input-otp.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Fragment, Host, Prop, State, h, Watch } from '@stencil/core'; +import { AttachInternals, Component, Element, Event, Fragment, Host, Prop, State, h, Watch } from '@stencil/core'; +import { reportValidityToElementInternals } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; @@ -58,6 +59,8 @@ export class InputOTP implements ComponentInterface { @Element() el!: HTMLIonInputOtpElement; + @AttachInternals() internals!: ElementInternals; + @State() private inputValues: string[] = []; @State() hasFocus = false; @State() private previousInputValues: string[] = []; @@ -80,6 +83,14 @@ export class InputOTP implements ComponentInterface { */ @Prop({ reflect: true }) disabled = false; + /** + * Update element internals when disabled prop changes + */ + @Watch('disabled') + protected disabledChanged() { + this.updateElementInternals(); + } + /** * The fill for the input boxes. If `"solid"` the input boxes will have a background. If * `"outline"` the input boxes will be transparent with a border. @@ -208,6 +219,7 @@ export class InputOTP implements ComponentInterface { valueChanged() { this.initializeValues(); this.updateTabIndexes(); + this.updateElementInternals(); } /** @@ -283,6 +295,7 @@ export class InputOTP implements ComponentInterface { componentDidLoad() { this.updateTabIndexes(); + this.updateElementInternals(); } /** @@ -367,6 +380,42 @@ export class InputOTP implements ComponentInterface { } } + /** + * Gets the value of the input group as a string for form submission. + * Returns an empty string if the value is null or undefined. + */ + private getValue(): string { + return this.value != null ? this.value.toString() : ''; + } + + /** + * 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); + } + // Use the first input element for validity reporting since all inputs + // share the same validation state + reportValidityToElementInternals(this.inputRefs[0] ?? null, this.internals); + } + /** * Emits an `ionChange` event. * This API should be called for user committed changes. From 87d636f4e6940cea7600b3a52f5d842d55bf6bd7 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:53:01 -0500 Subject: [PATCH 6/6] test(react, vue): fix typing in input otp tests --- .../react/test/base/tests/e2e/specs/components/inputs.cy.ts | 6 +++++- packages/vue/test/base/tests/e2e/specs/inputs.cy.js | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) 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 48ea4aa7964..b2b244ad3f5 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 @@ -55,7 +55,11 @@ describe('Inputs', () => { }); it('typing into input-otp should update ref', () => { - cy.get('ion-input-otp').shadow().find('input').eq(0).type('1234', { scrollBehavior: false }); + cy.get('ion-input-otp').shadow().find('input').eq(0).focus(); + cy.get('ion-input-otp').shadow().find('input').eq(0).type('1', { scrollBehavior: false }); + cy.get('ion-input-otp').shadow().find('input').eq(1).type('2', { scrollBehavior: false }); + cy.get('ion-input-otp').shadow().find('input').eq(2).type('3', { scrollBehavior: false }); + cy.get('ion-input-otp').shadow().find('input').eq(3).type('4', { scrollBehavior: false }); cy.get('#input-otp-ref').should('have.text', '1234'); }); 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 fc6f91cb6e2..c72eae3aae6 100644 --- a/packages/vue/test/base/tests/e2e/specs/inputs.cy.js +++ b/packages/vue/test/base/tests/e2e/specs/inputs.cy.js @@ -53,7 +53,11 @@ describe('Inputs', () => { cy.get('#input-ref').should('have.text', 'Hello Input'); }); it('typing into input-otp should update ref', () => { - cy.get('ion-input-otp').shadow().find('input').eq(0).type('1234', { scrollBehavior: false }); + cy.get('ion-input-otp').shadow().find('input').eq(0).focus(); + cy.get('ion-input-otp').shadow().find('input').eq(0).type('1', { scrollBehavior: false }); + cy.get('ion-input-otp').shadow().find('input').eq(1).type('2', { scrollBehavior: false }); + cy.get('ion-input-otp').shadow().find('input').eq(2).type('3', { scrollBehavior: false }); + cy.get('ion-input-otp').shadow().find('input').eq(3).type('4', { scrollBehavior: false }); cy.get('#input-otp-ref').should('have.text', '1234'); });