Skip to content

Commit 177a6ea

Browse files
feat(input-otp): convert to a form associated shadow component #30785 (#30884)
Issue number: internal --------- ## What is the current behavior? Input Otp uses `scoped` encapsulation. This causes issues with CSP compatibility and is inconsistent with our goal of having all components use Shadow DOM. ## What is the new behavior? - Converts `ion-input-otp` to `shadow` with `formAssociated: true` - Adds shadow parts for inner elements ## Does this introduce a breaking change? - [x] Yes - [ ] No BREAKING CHANGE: Input Otp has been converted 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. --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
1 parent 1d36021 commit 177a6ea

12 files changed

Lines changed: 599 additions & 10 deletions

File tree

BREAKING.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver
2020
- [Chip](#version-9x-chip)
2121
- [Datetime](#version-9x-datetime)
2222
- [Grid](#version-9x-grid)
23+
- [Input Otp](#version-9x-input-otp)
2324
- [Radio Group](#version-9x-radio-group)
2425
- [Textarea](#version-9x-textarea)
2526

@@ -185,6 +186,12 @@ To reorder two columns where column 1 has `size="9" push="3"` and column 2 has `
185186
</ion-grid>
186187
```
187188

189+
<h4 id="version-9x-input-otp">Input Otp</h4>
190+
191+
Converted `ion-input-otp` to use [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM).
192+
193+
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.
194+
188195
<h4 id="version-9x-radio-group">Radio Group</h4>
189196

190197
Converted `ion-radio-group` to use [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM).

core/api.txt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1035,18 +1035,20 @@ ion-input,css-prop,--placeholder-opacity,ionic
10351035
ion-input,css-prop,--placeholder-opacity,ios
10361036
ion-input,css-prop,--placeholder-opacity,md
10371037

1038-
ion-input-otp,scoped
1038+
ion-input-otp,shadow
10391039
ion-input-otp,prop,autocapitalize,string,'off',false,false
10401040
ion-input-otp,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record<never, never> | undefined,undefined,false,true
10411041
ion-input-otp,prop,disabled,boolean,false,false,true
10421042
ion-input-otp,prop,fill,"outline" | "solid" | undefined,'outline',false,false
10431043
ion-input-otp,prop,inputmode,"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined,undefined,false,false
10441044
ion-input-otp,prop,length,number,4,false,false
1045+
ion-input-otp,prop,mode,"ios" | "md",undefined,false,false
10451046
ion-input-otp,prop,pattern,string | undefined,undefined,false,false
10461047
ion-input-otp,prop,readonly,boolean,false,false,true
10471048
ion-input-otp,prop,separators,number[] | string | undefined,undefined,false,false
10481049
ion-input-otp,prop,shape,"rectangular" | "round" | "soft",'round',false,false
10491050
ion-input-otp,prop,size,"large" | "medium" | "small",'medium',false,false
1051+
ion-input-otp,prop,theme,"ios" | "md" | "ionic",undefined,false,false
10501052
ion-input-otp,prop,type,"number" | "text",'number',false,false
10511053
ion-input-otp,prop,value,null | number | string | undefined,'',false,false
10521054
ion-input-otp,method,setFocus,setFocus(index?: number) => Promise<void>
@@ -1127,6 +1129,11 @@ ion-input-otp,css-prop,--separator-width,md
11271129
ion-input-otp,css-prop,--width,ionic
11281130
ion-input-otp,css-prop,--width,ios
11291131
ion-input-otp,css-prop,--width,md
1132+
ion-input-otp,part,container
1133+
ion-input-otp,part,description
1134+
ion-input-otp,part,group
1135+
ion-input-otp,part,native
1136+
ion-input-otp,part,separator
11301137

11311138
ion-input-password-toggle,shadow
11321139
ion-input-password-toggle,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record<never, never> | undefined,undefined,false,true

core/src/components.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,10 @@ export namespace Components {
17791779
* @default 4
17801780
*/
17811781
"length": number;
1782+
/**
1783+
* The mode determines the platform behaviors of the component.
1784+
*/
1785+
"mode"?: "ios" | "md";
17821786
/**
17831787
* 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}]"`
17841788
*/
@@ -1807,6 +1811,10 @@ export namespace Components {
18071811
* @default 'medium'
18081812
*/
18091813
"size": 'small' | 'medium' | 'large';
1814+
/**
1815+
* The theme determines the visual appearance of the component.
1816+
*/
1817+
"theme"?: "ios" | "md" | "ionic";
18101818
/**
18111819
* The type of input allowed in the input boxes.
18121820
* @default 'number'
@@ -7762,6 +7770,10 @@ declare namespace LocalJSX {
77627770
* @default 4
77637771
*/
77647772
"length"?: number;
7773+
/**
7774+
* The mode determines the platform behaviors of the component.
7775+
*/
7776+
"mode"?: "ios" | "md";
77657777
/**
77667778
* Emitted when the input group loses focus.
77677779
*/
@@ -7805,6 +7817,10 @@ declare namespace LocalJSX {
78057817
* @default 'medium'
78067818
*/
78077819
"size"?: 'small' | 'medium' | 'large';
7820+
/**
7821+
* The theme determines the visual appearance of the component.
7822+
*/
7823+
"theme"?: "ios" | "md" | "ionic";
78087824
/**
78097825
* The type of input allowed in the input boxes.
78107826
* @default 'number'

core/src/components/input-otp/input-otp.common.scss

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,13 @@
9494
background: var(--background);
9595
color: var(--color);
9696

97+
font-family: inherit;
9798
font-size: inherit;
9899

99100
text-align: center;
100101
appearance: none;
102+
103+
box-sizing: border-box;
101104
}
102105

103106
:host(.has-focus) .native-input {

core/src/components/input-otp/input-otp.native.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
--highlight-color-valid: #{ion-color(success, base)};
2020
--highlight-color-invalid: #{ion-color(danger, base)};
2121

22+
font-family: $font-family-base;
23+
2224
font-size: dynamic-font(14px);
2325
}
2426

core/src/components/input-otp/input-otp.tsx

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
2-
import { Component, Element, Event, Fragment, Host, Prop, State, h, Watch } from '@stencil/core';
2+
import { AttachInternals, Component, Element, Event, Fragment, Host, Prop, State, h, Watch } from '@stencil/core';
3+
import { reportValidityToElementInternals } from '@utils/forms';
34
import type { Attributes } from '@utils/helpers';
45
import { inheritAriaAttributes } from '@utils/helpers';
56
import { printIonWarning } from '@utils/logging';
@@ -16,14 +17,25 @@ import type {
1617
InputOtpInputEventDetail,
1718
} from './input-otp-interface';
1819

20+
/**
21+
* @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component.
22+
* @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component.
23+
*
24+
* @part group - The container element that wraps all input boxes.
25+
* @part container - The wrapper element for each individual input box.
26+
* @part native - The native input element.
27+
* @part separator - The separator element displayed between input boxes.
28+
* @part description - The container element for the description text.
29+
*/
1930
@Component({
2031
tag: 'ion-input-otp',
2132
styleUrls: {
2233
ios: 'input-otp.ios.scss',
2334
md: 'input-otp.md.scss',
2435
ionic: 'input-otp.ionic.scss',
2536
},
26-
scoped: true,
37+
shadow: true,
38+
formAssociated: true,
2739
})
2840
export class InputOTP implements ComponentInterface {
2941
private inheritedAttributes: Attributes = {};
@@ -47,6 +59,8 @@ export class InputOTP implements ComponentInterface {
4759

4860
@Element() el!: HTMLIonInputOtpElement;
4961

62+
@AttachInternals() internals!: ElementInternals;
63+
5064
@State() private inputValues: string[] = [];
5165
@State() hasFocus = false;
5266
@State() private previousInputValues: string[] = [];
@@ -69,6 +83,14 @@ export class InputOTP implements ComponentInterface {
6983
*/
7084
@Prop({ reflect: true }) disabled = false;
7185

86+
/**
87+
* Update element internals when disabled prop changes
88+
*/
89+
@Watch('disabled')
90+
protected disabledChanged() {
91+
this.updateElementInternals();
92+
}
93+
7294
/**
7395
* The fill for the input boxes. If `"solid"` the input boxes will have a background. If
7496
* `"outline"` the input boxes will be transparent with a border.
@@ -197,6 +219,7 @@ export class InputOTP implements ComponentInterface {
197219
valueChanged() {
198220
this.initializeValues();
199221
this.updateTabIndexes();
222+
this.updateElementInternals();
200223
}
201224

202225
/**
@@ -272,6 +295,7 @@ export class InputOTP implements ComponentInterface {
272295

273296
componentDidLoad() {
274297
this.updateTabIndexes();
298+
this.updateElementInternals();
275299
}
276300

277301
/**
@@ -356,6 +380,50 @@ export class InputOTP implements ComponentInterface {
356380
}
357381
}
358382

383+
/**
384+
* Gets the value of the input group as a string for form submission.
385+
* Returns an empty string if the value is null or undefined.
386+
*/
387+
private getValue(): string {
388+
return this.value != null ? this.value.toString() : '';
389+
}
390+
391+
/**
392+
* Called when the form state is restored.
393+
* Restores the component's value.
394+
*/
395+
formStateRestoreCallback(value: string) {
396+
this.value = value;
397+
}
398+
399+
/**
400+
* Called when the form is reset.
401+
* Resets the component's value.
402+
*/
403+
formResetCallback() {
404+
this.value = '';
405+
}
406+
407+
/**
408+
* Updates the form value and reports validity state to the browser via
409+
* ElementInternals. This should be called when the component loads, when
410+
* the disabled prop changes, and when the value changes to ensure the form
411+
* value stays in sync and validation state is updated.
412+
*/
413+
private updateElementInternals() {
414+
// Disabled form controls should not be included in form data
415+
// Pass null to setFormValue when disabled to exclude it from form submission
416+
const value = this.disabled ? null : this.getValue();
417+
// ElementInternals may not be fully available in test environments
418+
// so we need to check if the method exists before calling it
419+
if (typeof this.internals.setFormValue === 'function') {
420+
this.internals.setFormValue(value);
421+
}
422+
// Use the first input element for validity reporting since all inputs
423+
// share the same validation state
424+
reportValidityToElementInternals(this.inputRefs[0] ?? null, this.internals);
425+
}
426+
359427
/**
360428
* Emits an `ionChange` event.
361429
* This API should be called for user committed changes.
@@ -817,12 +885,19 @@ export class InputOTP implements ComponentInterface {
817885
'input-otp-readonly': readonly,
818886
})}
819887
>
820-
<div role="group" aria-label="One-time password input" class="input-otp-group" {...inheritedAttributes}>
888+
<div
889+
role="group"
890+
aria-label="One-time password input"
891+
class="input-otp-group"
892+
part="group"
893+
{...inheritedAttributes}
894+
>
821895
{Array.from({ length }).map((_, index) => (
822896
<>
823-
<div class="native-wrapper">
897+
<div class="native-wrapper" part="container">
824898
<input
825899
class="native-input"
900+
part="native"
826901
id={`${inputId}-${index}`}
827902
aria-label={`Input ${index + 1} of ${length}`}
828903
type="text"
@@ -842,7 +917,7 @@ export class InputOTP implements ComponentInterface {
842917
onPaste={this.onPaste}
843918
/>
844919
</div>
845-
{this.showSeparator(index) && <div class="input-otp-separator" />}
920+
{this.showSeparator(index) && <div class="input-otp-separator" part="separator" />}
846921
</>
847922
))}
848923
</div>
@@ -851,6 +926,7 @@ export class InputOTP implements ComponentInterface {
851926
'input-otp-description': true,
852927
'input-otp-description-hidden': !hasDescription,
853928
}}
929+
part="description"
854930
>
855931
<slot></slot>
856932
</div>

0 commit comments

Comments
 (0)