diff --git a/BREAKING.md b/BREAKING.md index 0c1d6c6f054..d1cb7b81d57 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 fa4a86fc8a4..3d12adf8dc9 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..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'; @@ -16,6 +17,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 +34,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 = {}; @@ -47,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[] = []; @@ -69,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. @@ -197,6 +219,7 @@ export class InputOTP implements ComponentInterface { valueChanged() { this.initializeValues(); this.updateTabIndexes(); + this.updateElementInternals(); } /** @@ -272,6 +295,7 @@ export class InputOTP implements ComponentInterface { componentDidLoad() { this.updateTabIndexes(); + this.updateElementInternals(); } /** @@ -356,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. @@ -817,12 +877,19 @@ export class InputOTP implements ComponentInterface { 'input-otp-readonly': readonly, })} > -
+
{Array.from({ length }).map((_, index) => ( <> -
+
- {this.showSeparator(index) &&
} + {this.showSeparator(index) &&
} ))}
@@ -851,6 +918,7 @@ export class InputOTP implements ComponentInterface { 'input-otp-description': true, 'input-otp-description-hidden': !hasDescription, }} + part="description" >
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)'); + }); + }); +}); 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..6a06830d87b --- /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:

+

+        
+
+ + +
+ + diff --git a/core/src/components/input-otp/test/form/input-otp.e2e.ts b/core/src/components/input-otp/test/form/input-otp.e2e.ts new file mode 100644 index 00000000000..25e820d1e1f --- /dev/null +++ b/core/src/components/input-otp/test/form/input-otp.e2e.ts @@ -0,0 +1,148 @@ +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: form'), () => { + test('should set formData when submit button is clicked', async ({ page }) => { + await page.setContent( + ` +
+ Description + +
+ + `, + config + ); + + const submitButton = page.locator('button[type="submit"]'); + const firstInput = page.locator('ion-input-otp input').first(); + + // Type into the first input box - this will fill all 4 boxes + await firstInput.focus(); + await page.keyboard.type('1234'); + + // Wait for any async operations to complete + await page.waitForChanges(); + + // 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?.['otp']).toBe('1234'); + }); + + test('should reset formData when reset button is clicked', async ({ page }) => { + await page.setContent( + ` +
+ + + +
+ + `, + config + ); + + const inputOtp = page.locator('ion-input-otp'); + const submitButton = page.locator('button[type="submit"]'); + const resetButton = page.locator('button[type="reset"]'); + const firstInput = page.locator('ion-input-otp input').first(); + + // Type into the first input box - this will fill all 4 boxes + await firstInput.focus(); + await page.keyboard.type('1234'); + + // Wait for any async operations to complete + await page.waitForChanges(); + + // 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?.['otp']).toBe('1234'); + + // Click reset button - form should reset + await resetButton.click(); + + // Wait for any async operations to complete + await page.waitForChanges(); + + // Verify that the input-otp's value is cleared + const inputOtpValue = await inputOtp.evaluate((el: HTMLIonInputOtpElement) => { + return el.value ?? ''; + }); + expect(inputOtpValue).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?.['otp']).toBe(''); + }); + }); +}); 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; 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..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 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 91d33d0c8b2..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 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'); });