diff --git a/core/api.txt b/core/api.txt index 5a79c3903aa..074a30b2035 100644 --- a/core/api.txt +++ b/core/api.txt @@ -794,7 +794,10 @@ ion-input-otp,scoped 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,errorText,string | undefined,undefined,false,false ion-input-otp,prop,fill,"outline" | "solid" | undefined,'outline',false,false +ion-input-otp,prop,helperText,string | undefined,undefined,false,false +ion-input-otp,prop,inputLabelFormatter,((index: number, length: number) => string) | undefined,undefined,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,pattern,string | undefined,undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 22e751460f0..2816528dc72 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -18,7 +18,7 @@ import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-int import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimeParts, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; import { SpinnerTypes } from "./components/spinner/spinner-configs"; import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; -import { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface"; +import { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail, InputOtpLabelFormatter } from "./components/input-otp/input-otp-interface"; import { MenuChangeEventDetail, MenuCloseEventDetail, MenuType, Side } from "./components/menu/menu-interface"; import { ModalBreakpointChangeEventDetail, ModalDragEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface"; import { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface"; @@ -55,7 +55,7 @@ export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-int export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimeParts, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; export { SpinnerTypes } from "./components/spinner/spinner-configs"; export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; -export { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface"; +export { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail, InputOtpLabelFormatter } from "./components/input-otp/input-otp-interface"; export { MenuChangeEventDetail, MenuCloseEventDetail, MenuType, Side } from "./components/menu/menu-interface"; export { ModalBreakpointChangeEventDetail, ModalDragEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface"; export { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface"; @@ -1468,11 +1468,23 @@ export namespace Components { * @default false */ "disabled": boolean; + /** + * Text that is placed under the input boxes and displayed when an error is detected. + */ + "errorText"?: string; /** * 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. * @default 'outline' */ "fill"?: 'outline' | 'solid'; + /** + * Text that is placed under the input boxes and displayed when no error is detected. + */ + "helperText"?: string; + /** + * A callback used to format the accessible name of each input box. It receives the 0-based `index` of the box and the total `length`. By default the name is "Input 1 of 4", or "PIN, 1 of 4" with an `aria-label` of "PIN". See https://ionicframework.com/docs/troubleshooting/runtime#accessing-this if you need to access `this` from within the callback. + */ + "inputLabelFormatter"?: InputOtpLabelFormatter; /** * A hint to the browser for which keyboard to display. Possible values: `"none"`, `"text"`, `"tel"`, `"url"`, `"email"`, `"numeric"`, `"decimal"`, and `"search"`. For numbers (type="number"): "numeric" For text (type="text"): "text" */ @@ -6628,11 +6640,23 @@ declare namespace LocalJSX { * @default false */ "disabled"?: boolean; + /** + * Text that is placed under the input boxes and displayed when an error is detected. + */ + "errorText"?: string; /** * 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. * @default 'outline' */ "fill"?: 'outline' | 'solid'; + /** + * Text that is placed under the input boxes and displayed when no error is detected. + */ + "helperText"?: string; + /** + * A callback used to format the accessible name of each input box. It receives the 0-based `index` of the box and the total `length`. By default the name is "Input 1 of 4", or "PIN, 1 of 4" with an `aria-label` of "PIN". See https://ionicframework.com/docs/troubleshooting/runtime#accessing-this if you need to access `this` from within the callback. + */ + "inputLabelFormatter"?: InputOtpLabelFormatter; /** * A hint to the browser for which keyboard to display. Possible values: `"none"`, `"text"`, `"tel"`, `"url"`, `"email"`, `"numeric"`, `"decimal"`, and `"search"`. For numbers (type="number"): "numeric" For text (type="text"): "text" */ @@ -9236,7 +9260,9 @@ declare namespace LocalJSX { "autocapitalize": string; "color": Color; "disabled": boolean; + "errorText": string; "fill": 'outline' | 'solid'; + "helperText": string; "inputmode": 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'; "length": number; "pattern": string; diff --git a/core/src/components/input-otp/input-otp-interface.ts b/core/src/components/input-otp/input-otp-interface.ts index 8fb289cfec4..8d7daf48011 100644 --- a/core/src/components/input-otp/input-otp-interface.ts +++ b/core/src/components/input-otp/input-otp-interface.ts @@ -17,6 +17,8 @@ export interface InputOtpCompleteEventDetail { event?: Event; } +export type InputOtpLabelFormatter = (index: number, length: number) => string; + export interface InputOtpCustomEvent extends CustomEvent { detail: T; target: HTMLIonInputOtpElement; diff --git a/core/src/components/input-otp/input-otp.ios.scss b/core/src/components/input-otp/input-otp.ios.scss index 8f0e8176784..fd4eaa6b96c 100644 --- a/core/src/components/input-otp/input-otp.ios.scss +++ b/core/src/components/input-otp/input-otp.ios.scss @@ -16,5 +16,5 @@ // -------------------------------------------------- :host(.input-otp-fill-outline) { - --border-color: #{$item-ios-border-color}; + --internal-border-color: #{$item-ios-border-color}; } diff --git a/core/src/components/input-otp/input-otp.md.scss b/core/src/components/input-otp/input-otp.md.scss index f4e2b16e97e..60563673fa8 100644 --- a/core/src/components/input-otp/input-otp.md.scss +++ b/core/src/components/input-otp/input-otp.md.scss @@ -16,5 +16,5 @@ // -------------------------------------------------- :host(.input-otp-fill-outline) { - --border-color: #{$background-color-step-300}; + --internal-border-color: #{$background-color-step-300}; } diff --git a/core/src/components/input-otp/input-otp.scss b/core/src/components/input-otp/input-otp.scss index 2b8b12ee585..a47bd460652 100644 --- a/core/src/components/input-otp/input-otp.scss +++ b/core/src/components/input-otp/input-otp.scss @@ -63,6 +63,9 @@ */ --highlight-color: var(--highlight-color-focused); + // Don't inherit `--border-color` from ancestors like `ion-modal` + --border-color: initial; + display: block; position: relative; @@ -102,7 +105,9 @@ border-width: var(--border-width); border-style: solid; - border-color: var(--border-color); + // The defaults use `--internal-border-color` since a host + // selector can't beat `:host(.input-otp-fill-*)` + border-color: var(--border-color, var(--internal-border-color)); background: var(--background); color: var(--color); @@ -134,6 +139,37 @@ display: none; } +// Input Hint Text +// ---------------------------------------------------------------- + +.input-otp-bottom { + font-size: dynamic-font(12px); + + line-height: dynamic-font(20px); + + text-align: center; +} + +.input-otp-bottom .error-text { + display: none; + + color: var(--highlight-color-invalid); +} + +.input-otp-bottom .helper-text { + display: block; + + color: $text-color-step-300; +} + +:host(.ion-touched.ion-invalid) .input-otp-bottom .error-text { + display: block; +} + +:host(.ion-touched.ion-invalid) .input-otp-bottom .helper-text { + display: none; +} + // Input Separator // ---------------------------------------------------------------- @@ -198,7 +234,7 @@ } :host(.input-otp-fill-solid) { - --border-color: #{$background-color-step-50}; + --internal-border-color: #{$background-color-step-50}; --background: #{$background-color-step-50}; } @@ -211,7 +247,7 @@ :host(.input-otp-fill-outline.input-otp-disabled) { --background: #{$background-color-step-50}; - --border-color: #{$background-color-step-100}; + --internal-border-color: #{$background-color-step-100}; } :host(.input-otp-disabled), @@ -231,7 +267,7 @@ :host(.input-otp-fill-solid.input-otp-disabled), :host(.input-otp-fill-solid.input-otp-readonly) { - --border-color: #{$background-color-step-100}; + --internal-border-color: #{$background-color-step-100}; --background: #{$background-color-step-100}; } diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx index 1c84b3c9a42..59955488d45 100644 --- a/core/src/components/input-otp/input-otp.tsx +++ b/core/src/components/input-otp/input-otp.tsx @@ -1,8 +1,24 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Fragment, Host, Method, Prop, State, h, Watch } from '@stencil/core'; +import { + Build, + Component, + Element, + Event, + Fragment, + Host, + Method, + Prop, + State, + forceUpdate, + h, + Watch, +} from '@stencil/core'; +import type { AttributeController } from '@utils/attribute-controller'; +import { createAttributeController } from '@utils/attribute-controller'; +import { checkInvalidState } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; -import { inheritAriaAttributes } from '@utils/helpers'; -import { printIonWarning } from '@utils/logging'; +import { ariaAttributes } from '@utils/helpers'; +import { printIonError, printIonWarning } from '@utils/logging'; import { isRTL } from '@utils/rtl'; import { createColorClasses } from '@utils/theme'; @@ -13,6 +29,7 @@ import type { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail, + InputOtpLabelFormatter, } from './input-otp-interface'; @Component({ @@ -24,9 +41,13 @@ import type { scoped: true, }) export class InputOTP implements ComponentInterface { - private inheritedAttributes: Attributes = {}; + private ariaController?: AttributeController; private inputRefs: HTMLInputElement[] = []; private inputId = `ion-input-otp-${inputIds++}`; + private helperTextId = `${this.inputId}-helper-text`; + private errorTextId = `${this.inputId}-error-text`; + private descriptionId = `${this.inputId}-description`; + private validationObserver?: MutationObserver; private parsedSeparators: number[] = []; /** @@ -55,6 +76,8 @@ export class InputOTP implements ComponentInterface { @State() hasFocus = false; @State() private previousInputValues: string[] = []; + @State() isInvalid = false; + /** * Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user. * Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`. @@ -73,12 +96,32 @@ export class InputOTP implements ComponentInterface { */ @Prop({ reflect: true }) disabled = false; + /** + * Text that is placed under the input boxes and displayed when an error is detected. + */ + @Prop() errorText?: string; + /** * 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. */ @Prop() fill?: 'outline' | 'solid' = 'outline'; + /** + * Text that is placed under the input boxes and displayed when no error is detected. + */ + @Prop() helperText?: string; + + /** + * A callback used to format the accessible name of each input box. It + * receives the 0-based `index` of the box and the total `length`. By default + * the name is "Input 1 of 4", or "PIN, 1 of 4" with an `aria-label` of "PIN". + * + * See https://ionicframework.com/docs/troubleshooting/runtime#accessing-this + * if you need to access `this` from within the callback. + */ + @Prop() inputLabelFormatter?: InputOtpLabelFormatter; + /** * A hint to the browser for which keyboard to display. * Possible values: `"none"`, `"text"`, `"tel"`, `"url"`, @@ -197,6 +240,13 @@ export class InputOTP implements ComponentInterface { } } + // The host keeps these (see `HOST_ARIA_ATTRIBUTES`), so re-render when they change + @Watch('aria-describedby') + @Watch('aria-invalid') + onHostAriaChange() { + forceUpdate(this); + } + @Watch('value') valueChanged() { this.initializeValues(); @@ -274,14 +324,59 @@ export class InputOTP implements ComponentInterface { this.parsedSeparators = separatorValues.filter((pos) => pos <= length); } + connectedCallback() { + const { el } = this; + + if (Build.isBrowser && typeof MutationObserver !== 'undefined') { + this.validationObserver = new MutationObserver(() => { + const newIsInvalid = checkInvalidState(el); + if (this.isInvalid !== newIsInvalid) { + this.isInvalid = newIsInvalid; + // Force a re-render to update aria-describedby immediately + forceUpdate(this); + } + }); + + this.validationObserver.observe(el, { + attributes: true, + attributeFilter: ['class'], + }); + } + + this.isInvalid = checkInvalidState(el); + + // Resumes watching after a move, since `componentWillLoad` only runs once + this.ariaController?.init(); + } + + disconnectedCallback() { + this.ariaController?.destroy(); + + if (this.validationObserver) { + this.validationObserver.disconnect(); + this.validationObserver = undefined; + } + } + componentWillLoad() { - this.inheritedAttributes = inheritAriaAttributes(this.el); + // Only read `role` at load so a later host write isn't copied to the group + this.ariaController = createAttributeController( + this.el, + ariaAttributes.filter((attr) => !HOST_ARIA_ATTRIBUTES.includes(attr)), + () => forceUpdate(this), + ['role'] + ); this.processSeparators(); this.initializeValues(); } componentDidLoad() { this.updateTabIndexes(); + + // The description only exists after the first render, so render again to reference it + if (this.getDescriptionText()) { + forceUpdate(this); + } } componentDidRender() { @@ -818,18 +913,90 @@ export class InputOTP implements ComponentInterface { return this.parsedSeparators.includes(index + 1) && index < length - 1; } + /** + * Returns `undefined` until the first render creates the description container. + */ + private getDescriptionText() { + return this.el.querySelector('.input-otp-description')?.textContent?.trim(); + } + + private getHintTextId(): string | undefined { + const { isInvalid, helperText, errorText, helperTextId, errorTextId } = this; + + if (isInvalid && errorText) { + return errorTextId; + } + + if (helperText) { + return helperTextId; + } + + return undefined; + } + + private renderHintText() { + const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this; + + return [ +
+ {!isInvalid ? helperText : null} +
, + , + ]; + } + + private renderBottomContent() { + const { helperText, errorText } = this; + + if (!helperText && !errorText) { + return; + } + + return
{this.renderHintText()}
; + } + + /** + * A custom formatter is used as is so translators control the whole name. + * With a host `aria-labelledby`, each box also references itself so its + * position is still read. + */ + private getInputLabelAttributes(index: number, hostAttributes: Attributes) { + const { inputId, inputLabelFormatter, length } = this; + const hostLabel = hostAttributes['aria-label']; + const hostLabelledBy = hostAttributes['aria-labelledby']; + const position = `${index + 1} of ${length}`; + + if (inputLabelFormatter !== undefined) { + try { + return { 'aria-label': inputLabelFormatter(index, length) }; + } catch (e) { + printIonError('[ion-input-otp] - Exception in provided `inputLabelFormatter`:', e); + } + } + + if (hostLabelledBy) { + return { 'aria-label': position, 'aria-labelledby': `${hostLabelledBy} ${inputId}-${index}` }; + } + + return { 'aria-label': hostLabel ? `${hostLabel}, ${position}` : `Input ${position}` }; + } + render() { const { + ariaController, autocapitalize, color, + descriptionId, disabled, el, fill, hasFocus, - inheritedAttributes, inputId, inputRefs, inputValues, + isInvalid, length, readonly, shape, @@ -839,7 +1006,22 @@ export class InputOTP implements ComponentInterface { const inputmode = this.getInputmode(); const tabbableIndex = this.getTabbableIndex(); const pattern = this.getPattern(); - const hasDescription = el.querySelector('.input-otp-description')?.textContent?.trim() !== ''; + const descriptionText = this.getDescriptionText(); + // Keep the description visible until the first render can check it + const hasDescription = descriptionText !== ''; + + /** + * Screen readers only announce these from the focused input. The + * description stays on the group so it isn't repeated for every input. + */ + const { + 'aria-errormessage': ariaErrorMessage, + 'aria-required': ariaRequired, + ...groupAttributes + } = ariaController?.attributes ?? {}; + const hostDescribedBy = el.getAttribute('aria-describedby'); + const hostInvalid = el.getAttribute('aria-invalid') ?? undefined; + const describedBy = [hostDescribedBy, this.getHintTextId()].filter(Boolean).join(' '); return ( -
+
{Array.from({ length }).map((_, index) => ( <>
+ {this.renderBottomContent()} ); } } let inputIds = 0; + +/** + * The attribute controller removes these from the host, so an app could never + * clear them. They stay on the host instead, which only works for global + * attributes since the host has no role. + */ +const HOST_ARIA_ATTRIBUTES = ['aria-describedby', 'aria-invalid']; diff --git a/core/src/components/input-otp/test/a11y/index.html b/core/src/components/input-otp/test/a11y/index.html new file mode 100644 index 00000000000..5b3cdc71c47 --- /dev/null +++ b/core/src/components/input-otp/test/a11y/index.html @@ -0,0 +1,64 @@ + + + + + Input OTP - a11y + + + + + + + + + +
+

Input OTP

+ +

Host aria-label, description and hint text

+ + Didn't get a code? + + + +

Host aria-labelledby

+

Verification code

+ + +

Label formatter

+ + +

Host aria-describedby and aria-invalid

+ +

The server rejected this code

+ +
+ + + + diff --git a/core/src/components/input-otp/test/a11y/input-otp.e2e.ts b/core/src/components/input-otp/test/a11y/input-otp.e2e.ts index 7cc5374d8ef..63f2085e66c 100644 --- a/core/src/components/input-otp/test/a11y/input-otp.e2e.ts +++ b/core/src/components/input-otp/test/a11y/input-otp.e2e.ts @@ -128,3 +128,116 @@ configs().forEach(({ title, config }) => { }); }); }); + +/** + * This behavior does not vary across modes/directions + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('input-otp: host aria attributes'), () => { + test('should forward aria-invalid and aria-describedby set on the host after load', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31619', + }); + + await page.setContent(``, config); + + const inputOtp = page.locator('ion-input-otp'); + const inputBoxes = page.locator('ion-input-otp input'); + + await inputOtp.evaluate((el) => { + el.setAttribute('aria-invalid', 'true'); + el.setAttribute('aria-describedby', 'server-error'); + }); + + for (const input of await inputBoxes.all()) { + await expect(input).toHaveAttribute('aria-invalid', 'true'); + await expect(input).toHaveAttribute('aria-describedby', 'server-error'); + } + }); + + test('should clear aria-invalid and aria-describedby from the inputs when removed from the host', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31619', + }); + + await page.setContent( + ``, + config + ); + + const inputOtp = page.locator('ion-input-otp'); + const inputBoxes = page.locator('ion-input-otp input'); + + await inputOtp.evaluate((el) => { + el.removeAttribute('aria-invalid'); + el.removeAttribute('aria-describedby'); + }); + + for (const input of await inputBoxes.all()) { + await expect(input).not.toHaveAttribute('aria-invalid'); + await expect(input).not.toHaveAttribute('aria-describedby'); + } + }); + + test('should rename the inputs when the host aria-label changes', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31619', + }); + + await page.setContent(``, config); + + await page.locator('ion-input-otp').evaluate((el) => el.setAttribute('aria-label', 'Code')); + + const inputBoxes = page.locator('ion-input-otp input'); + await expect(inputBoxes.nth(0)).toHaveAttribute('aria-label', 'Code, 1 of 2'); + await expect(inputBoxes.nth(1)).toHaveAttribute('aria-label', 'Code, 2 of 2'); + await expect(page.locator('ion-input-otp .input-otp-group')).toHaveAttribute('aria-label', 'Code'); + }); + + test('should describe the group, not each input, with the slotted description', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31619', + }); + + await page.setContent(`Didn't get a code?`, config); + + const description = page.locator('ion-input-otp .input-otp-description'); + await expect(description).toBeVisible(); + const descriptionId = await description.getAttribute('id'); + + await expect(page.locator('ion-input-otp .input-otp-group')).toHaveAttribute('aria-describedby', descriptionId!); + for (const input of await page.locator('ion-input-otp input').all()) { + await expect(input).not.toHaveAttribute('aria-describedby'); + } + }); + + test('should restore the default labels when a host aria-label set after load is removed', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31619', + }); + + await page.setContent(``, config); + + const inputOtp = page.locator('ion-input-otp'); + await inputOtp.evaluate((el) => el.setAttribute('aria-label', 'PIN')); + await expect(page.locator('ion-input-otp input').nth(0)).toHaveAttribute('aria-label', 'PIN, 1 of 2'); + + await inputOtp.evaluate((el) => el.removeAttribute('aria-label')); + + await expect(page.locator('ion-input-otp .input-otp-group')).toHaveAttribute( + 'aria-label', + 'One-time password input' + ); + await expect(page.locator('ion-input-otp input').nth(0)).toHaveAttribute('aria-label', 'Input 1 of 2'); + }); + }); +}); diff --git a/core/src/components/input-otp/test/basic/index.html b/core/src/components/input-otp/test/basic/index.html index 71e31de7aff..c43f0c21c17 100644 --- a/core/src/components/input-otp/test/basic/index.html +++ b/core/src/components/input-otp/test/basic/index.html @@ -77,11 +77,23 @@

Readonly

Invalid / Touched

- Description - Description + Description + Description

Valid / Focused

- Description - Description + Description + Description
diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts new file mode 100644 index 00000000000..70dd0d90ea5 --- /dev/null +++ b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts @@ -0,0 +1,104 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * The hint text styles are the same across modes and directions + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('input-otp: hint text rendering'), () => { + test('should not have visual regressions when rendering helper text with a description', async ({ page }) => { + await page.setContent(`Description`, config); + + const inputOtp = page.locator('ion-input-otp'); + await expect(inputOtp).toHaveScreenshot(screenshot(`input-otp-helper-text-description`)); + }); + + test('should not have visual regressions when rendering helper text with wrapping text', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const inputOtp = page.locator('ion-input-otp'); + await expect(inputOtp).toHaveScreenshot(screenshot(`input-otp-helper-text-wrapping`)); + }); + + test('should not have visual regressions when rendering error text', async ({ page }) => { + await page.setContent( + ``, + config + ); + + const inputOtp = page.locator('ion-input-otp'); + await expect(inputOtp).toHaveScreenshot(screenshot(`input-otp-error-text`)); + }); + }); +}); + +/** + * Customizing supporting text is the same across modes and directions + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('input-otp: hint text customization'), () => { + test('should not have visual regressions when rendering error text with a custom css variable', async ({ + page, + }) => { + await page.setContent( + ` + + + `, + config + ); + + const inputOtp = page.locator('ion-input-otp'); + await expect(inputOtp).toHaveScreenshot(screenshot(`input-otp-error-text-custom-css-var`)); + }); + }); +}); + +/** + * This behavior does not vary across modes/directions + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('input-otp: hint text functionality'), () => { + test('should switch every input between helper and error text as the validation classes change', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31619', + }); + + await page.setContent( + ``, + config + ); + + const inputOtp = page.locator('ion-input-otp'); + const inputBoxes = page.locator('ion-input-otp input'); + const helperTextId = await page.locator('ion-input-otp .helper-text').getAttribute('id'); + const errorTextId = await page.locator('ion-input-otp .error-text').getAttribute('id'); + + await inputOtp.evaluate((el) => el.classList.add('ion-touched', 'ion-invalid')); + + for (const input of await inputBoxes.all()) { + await expect(input).toHaveAttribute('aria-describedby', errorTextId!); + await expect(input).toHaveAttribute('aria-invalid', 'true'); + } + await expect(page.locator('ion-input-otp .error-text')).toHaveText('Wrong code'); + + await inputOtp.evaluate((el) => el.classList.remove('ion-invalid')); + + for (const input of await inputBoxes.all()) { + await expect(input).toHaveAttribute('aria-describedby', helperTextId!); + await expect(input).not.toHaveAttribute('aria-invalid'); + } + await expect(page.locator('ion-input-otp .helper-text')).toHaveText('Check your email'); + }); + }); +}); diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-custom-css-var-md-ltr-Mobile-Chrome-linux.png b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-custom-css-var-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..db20b7650d7 Binary files /dev/null and b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-custom-css-var-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-custom-css-var-md-ltr-Mobile-Firefox-linux.png b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-custom-css-var-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..be492fcd56c Binary files /dev/null and b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-custom-css-var-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-custom-css-var-md-ltr-Mobile-Safari-linux.png b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-custom-css-var-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..25f9d8864ea Binary files /dev/null and b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-custom-css-var-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-md-ltr-Mobile-Chrome-linux.png b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..7b5f852fc3f Binary files /dev/null and b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-md-ltr-Mobile-Firefox-linux.png b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..6eb5bd9992f Binary files /dev/null and b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-md-ltr-Mobile-Safari-linux.png b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..cb0ce1d9f0d Binary files /dev/null and b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-error-text-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-description-md-ltr-Mobile-Chrome-linux.png b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-description-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..7d16b124797 Binary files /dev/null and b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-description-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-description-md-ltr-Mobile-Firefox-linux.png b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-description-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..227ee9e6613 Binary files /dev/null and b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-description-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-description-md-ltr-Mobile-Safari-linux.png b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-description-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..7df4224d4ed Binary files /dev/null and b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-description-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-wrapping-md-ltr-Mobile-Chrome-linux.png b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-wrapping-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..0e8607d0831 Binary files /dev/null and b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-wrapping-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-wrapping-md-ltr-Mobile-Firefox-linux.png b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-wrapping-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..ee1b625dad5 Binary files /dev/null and b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-wrapping-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-wrapping-md-ltr-Mobile-Safari-linux.png b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-wrapping-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..e2678139b0c Binary files /dev/null and b/core/src/components/input-otp/test/bottom-content/input-otp.e2e.ts-snapshots/input-otp-helper-text-wrapping-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/input-otp/test/fill/input-otp.e2e.ts b/core/src/components/input-otp/test/fill/input-otp.e2e.ts index b20e93a4591..dbf49f91557 100644 --- a/core/src/components/input-otp/test/fill/input-otp.e2e.ts +++ b/core/src/components/input-otp/test/fill/input-otp.e2e.ts @@ -42,3 +42,58 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { }); }); }); + +/** + * The fill defaults differ between modes but not directions. + * These check computed colors since the value that wins is what's under test. + */ +configs({ directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('input-otp: fill border color'), () => { + VALID_FILLS.forEach((fill) => { + test(`${fill} fill should use a --border-color set on the host`, async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31619', + }); + + await page.setContent( + ` + + + `, + config + ); + + const input = page.locator('ion-input-otp input').first(); + await expect(input).toHaveCSS('border-top-color', 'rgb(255, 0, 0)'); + }); + + test(`${fill} fill should not inherit --border-color from an ancestor`, async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31619', + }); + + await page.setContent( + ` + +
+ +
+ `, + config + ); + + const referenceColor = await page + .locator('#reference input') + .first() + .evaluate((el) => getComputedStyle(el).borderTopColor); + await expect(page.locator('#nested input').first()).toHaveCSS('border-top-color', referenceColor); + }); + }); + }); +}); diff --git a/core/src/components/input-otp/test/input-otp.spec.ts b/core/src/components/input-otp/test/input-otp.spec.ts new file mode 100644 index 00000000000..39abe131198 --- /dev/null +++ b/core/src/components/input-otp/test/input-otp.spec.ts @@ -0,0 +1,215 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { InputOTP } from '../input-otp'; + +const getInputLabels = (page: { body: HTMLElement }) => + Array.from(page.body.querySelectorAll('ion-input-otp input')).map((input) => input.getAttribute('aria-label')); + +describe('input-otp: input labels', () => { + it('should label each input with its position by default', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + + expect(getInputLabels(page)).toEqual(['Input 1 of 4', 'Input 2 of 4', 'Input 3 of 4', 'Input 4 of 4']); + }); + + it('should prefix each input label with the host aria-label', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + + expect(getInputLabels(page)).toEqual(['PIN, 1 of 4', 'PIN, 2 of 4', 'PIN, 3 of 4', 'PIN, 4 of 4']); + }); + + it('should label each input with the output of inputLabelFormatter', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + + const inputOtp = page.body.querySelector('ion-input-otp')!; + inputOtp.inputLabelFormatter = (index: number, length: number) => `Ziffer ${index + 1} von ${length}`; + await page.waitForChanges(); + + expect(getInputLabels(page)).toEqual(['Ziffer 1 von 4', 'Ziffer 2 von 4', 'Ziffer 3 von 4', 'Ziffer 4 von 4']); + }); + + it('should fall back to the default label when inputLabelFormatter throws', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const inputOtp = page.body.querySelector('ion-input-otp')!; + inputOtp.inputLabelFormatter = () => { + throw new Error('formatter error'); + }; + await page.waitForChanges(); + + expect(getInputLabels(page)).toEqual(['PIN, 1 of 2', 'PIN, 2 of 2']); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); +}); + +describe('input-otp: hint text', () => { + it('should describe every input with the helper text', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + + const helperText = page.body.querySelector('ion-input-otp .helper-text')!; + expect(helperText.textContent).toBe('Check your email'); + + const inputs = Array.from(page.body.querySelectorAll('ion-input-otp input')); + inputs.forEach((input) => { + expect(input.getAttribute('aria-describedby')).toBe(helperText.id); + }); + }); +}); + +describe('input-otp: error text', () => { + it('should describe every input with the error text and mark it invalid when touched and invalid', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + + const errorText = page.body.querySelector('ion-input-otp .error-text')!; + expect(errorText.textContent).toBe('Wrong code'); + expect(page.body.querySelector('ion-input-otp .helper-text')!.textContent).toBe(''); + + const inputs = Array.from(page.body.querySelectorAll('ion-input-otp input')); + inputs.forEach((input) => { + expect(input.getAttribute('aria-describedby')).toBe(errorText.id); + expect(input.getAttribute('aria-invalid')).toBe('true'); + }); + }); + + it('should not mark the inputs invalid when not touched', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + + const helperText = page.body.querySelector('ion-input-otp .helper-text')!; + const inputs = Array.from(page.body.querySelectorAll('ion-input-otp input')); + inputs.forEach((input) => { + expect(input.getAttribute('aria-describedby')).toBe(helperText.id); + expect(input.hasAttribute('aria-invalid')).toBe(false); + }); + }); +}); + +describe('input-otp: description', () => { + it('should describe the group, not each input, with the slotted description', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: `Didn't get a code?`, + }); + await page.waitForChanges(); + + const description = page.body.querySelector('ion-input-otp .input-otp-description')!; + const helperText = page.body.querySelector('ion-input-otp .helper-text')!; + expect(description.id).not.toBe(''); + + const group = page.body.querySelector('ion-input-otp .input-otp-group')!; + expect(group.getAttribute('aria-describedby')).toBe(description.id); + + const inputs = Array.from(page.body.querySelectorAll('ion-input-otp input')); + inputs.forEach((input) => { + expect(input.getAttribute('aria-describedby')).toBe(helperText.id); + }); + }); + + it('should not reference the description when it is empty', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + + const group = page.body.querySelector('ion-input-otp .input-otp-group')!; + expect(group.hasAttribute('aria-describedby')).toBe(false); + + const inputs = Array.from(page.body.querySelectorAll('ion-input-otp input')); + inputs.forEach((input) => { + expect(input.hasAttribute('aria-describedby')).toBe(false); + }); + }); +}); + +describe('input-otp: host aria attributes', () => { + it('should merge the host aria-describedby with the hint text on every input', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + + const helperText = page.body.querySelector('ion-input-otp .helper-text')!; + const inputs = Array.from(page.body.querySelectorAll('ion-input-otp input')); + inputs.forEach((input) => { + expect(input.getAttribute('aria-describedby')).toBe(`server-error ${helperText.id}`); + }); + }); + + it('should forward the host aria-invalid to every input', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + + const inputs = Array.from(page.body.querySelectorAll('ion-input-otp input')); + inputs.forEach((input) => { + expect(input.getAttribute('aria-invalid')).toBe('true'); + }); + }); + + it('should forward aria-errormessage and aria-required to every input instead of the group', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + + const group = page.body.querySelector('ion-input-otp .input-otp-group')!; + expect(group.hasAttribute('aria-errormessage')).toBe(false); + expect(group.hasAttribute('aria-required')).toBe(false); + + const inputs = Array.from(page.body.querySelectorAll('ion-input-otp input')); + inputs.forEach((input) => { + expect(input.getAttribute('aria-errormessage')).toBe('server-error'); + expect(input.getAttribute('aria-required')).toBe('true'); + }); + }); + + it('should name every input from the host aria-labelledby followed by its position', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + + const group = page.body.querySelector('ion-input-otp .input-otp-group')!; + expect(group.getAttribute('aria-labelledby')).toBe('pin-label'); + + const inputs = Array.from(page.body.querySelectorAll('ion-input-otp input')); + expect(inputs.map((input) => input.getAttribute('aria-label'))).toEqual(['1 of 2', '2 of 2']); + inputs.forEach((input) => { + expect(input.getAttribute('aria-labelledby')).toBe(`pin-label ${input.id}`); + }); + }); + + it('should keep the group label when the host has an aria-label', async () => { + const page = await newSpecPage({ + components: [InputOTP], + html: ``, + }); + + const group = page.body.querySelector('ion-input-otp .input-otp-group')!; + expect(group.getAttribute('role')).toBe('group'); + expect(group.getAttribute('aria-label')).toBe('PIN'); + }); +}); diff --git a/packages/angular/src/lazy/directives/proxies.ts b/packages/angular/src/lazy/directives/proxies.ts index b6be5b42f03..ba57340314c 100644 --- a/packages/angular/src/lazy/directives/proxies.ts +++ b/packages/angular/src/lazy/directives/proxies.ts @@ -1115,7 +1115,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', 'errorText', 'fill', 'helperText', 'inputLabelFormatter', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'], methods: ['setFocus'] }) @Component({ @@ -1123,7 +1123,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', 'errorText', 'fill', 'helperText', 'inputLabelFormatter', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'], outputs: ['ionInput', 'ionChange', 'ionComplete', 'ionBlur', 'ionFocus'], standalone: false }) diff --git a/packages/angular/src/standalone/directives/input-otp.ts b/packages/angular/src/standalone/directives/input-otp.ts index fe6b983174f..9c1508950ff 100644 --- a/packages/angular/src/standalone/directives/input-otp.ts +++ b/packages/angular/src/standalone/directives/input-otp.ts @@ -25,7 +25,10 @@ const INPUT_OTP_INPUTS = [ 'autocapitalize', 'color', 'disabled', + 'errorText', 'fill', + 'helperText', + 'inputLabelFormatter', 'inputmode', 'length', 'pattern', diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index f3a1d3bede3..0831646d978 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -498,7 +498,10 @@ export const IonInputOtp: StencilVueComponent