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,
})}
>
-
+
+
+
+
+
+
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');
});