From 6c500fd6b2e2553c11fcddc9d86ac9a29f76e172 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Mon, 25 Mar 2024 13:22:06 -0400 Subject: [PATCH] feat(input): add input-password-toggle component (#29175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue number: Internal --------- ## What is the current behavior? When given a password input it is hard to know what users are typing as the contents of the input are obscured. As a result, it is a common pattern to have a button that lets users temporarily toggle the visibility of the password so they can correct any mistakes. Ionic currently has the infrastructure for developers to implement this on their own, but this use case is so common that the team thinks it is worth having this functionality built-in to Ionic. ## What is the new behavior? - Introduces the `ion-input-password-toggle` component. This component is a button that toggles the visibility of the text in the input it is slotted into. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information ⚠️ Give co-author credit to https://github.com/ionic-team/ionic-framework/pull/29141 on merge. Docs PR: https://github.com/ionic-team/ionic-docs/pull/3541 Note: We did not do the approach listed in the other PR due to https://github.com/ionic-team/ionic-framework/pull/29141#discussion_r1523631811. --------- Co-authored-by: OS-giulianasilva --- core/api.txt | 10 +- core/src/components.d.ts | 47 ++++++ ...lert-scale-ios-ltr-Mobile-Chrome-linux.png | Bin 17550 -> 17541 bytes .../input-password-toggle.scss | 0 .../input-password-toggle.tsx | 152 ++++++++++++++++++ .../test/a11y/input-password-toggle.e2e.ts | 23 +++ .../test/basic/index.html | 76 +++++++++ .../test/basic/input-password-toggle.e2e.ts | 50 ++++++ ...ord-toggle-ios-ltr-Mobile-Chrome-linux.png | Bin 0 -> 719 bytes ...rd-toggle-ios-ltr-Mobile-Firefox-linux.png | Bin 0 -> 748 bytes ...ord-toggle-ios-ltr-Mobile-Safari-linux.png | Bin 0 -> 624 bytes .../test/input-password-toggle.spec.tsx | 115 +++++++++++++ core/src/components/input/input.scss | 9 ++ core/src/components/input/input.tsx | 26 ++- .../angular/src/directives/proxies-list.ts | 1 + packages/angular/src/directives/proxies.ts | 22 +++ .../standalone/src/directives/proxies.ts | 25 +++ packages/react/src/components/proxies.ts | 2 + packages/vue/src/proxies.ts | 9 ++ 19 files changed, 563 insertions(+), 4 deletions(-) create mode 100644 core/src/components/input-password-toggle/input-password-toggle.scss create mode 100644 core/src/components/input-password-toggle/input-password-toggle.tsx create mode 100644 core/src/components/input-password-toggle/test/a11y/input-password-toggle.e2e.ts create mode 100644 core/src/components/input-password-toggle/test/basic/index.html create mode 100644 core/src/components/input-password-toggle/test/basic/input-password-toggle.e2e.ts create mode 100644 core/src/components/input-password-toggle/test/basic/input-password-toggle.e2e.ts-snapshots/input-password-toggle-ios-ltr-Mobile-Chrome-linux.png create mode 100644 core/src/components/input-password-toggle/test/basic/input-password-toggle.e2e.ts-snapshots/input-password-toggle-ios-ltr-Mobile-Firefox-linux.png create mode 100644 core/src/components/input-password-toggle/test/basic/input-password-toggle.e2e.ts-snapshots/input-password-toggle-ios-ltr-Mobile-Safari-linux.png create mode 100644 core/src/components/input-password-toggle/test/input-password-toggle.spec.tsx diff --git a/core/api.txt b/core/api.txt index 4c25d93691a..f4fe3f92905 100644 --- a/core/api.txt +++ b/core/api.txt @@ -558,7 +558,7 @@ ion-input,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secon ion-input,prop,counter,boolean,false,false,false ion-input,prop,counterFormatter,((inputLength: number, maxLength: number) => string) | undefined,undefined,false,false ion-input,prop,debounce,number | undefined,undefined,false,false -ion-input,prop,disabled,boolean,false,false,false +ion-input,prop,disabled,boolean,false,false,true ion-input,prop,enterkeyhint,"done" | "enter" | "go" | "next" | "previous" | "search" | "send" | undefined,undefined,false,false ion-input,prop,errorText,string | undefined,undefined,false,false ion-input,prop,fill,"outline" | "solid" | undefined,undefined,false,false @@ -575,7 +575,7 @@ ion-input,prop,multiple,boolean | undefined,undefined,false,false ion-input,prop,name,string,this.inputId,false,false ion-input,prop,pattern,string | undefined,undefined,false,false ion-input,prop,placeholder,string | undefined,undefined,false,false -ion-input,prop,readonly,boolean,false,false,false +ion-input,prop,readonly,boolean,false,false,true ion-input,prop,required,boolean,false,false,false ion-input,prop,shape,"round" | undefined,undefined,false,false ion-input,prop,spellcheck,boolean,false,false,false @@ -607,6 +607,12 @@ ion-input,css-prop,--placeholder-font-style ion-input,css-prop,--placeholder-font-weight ion-input,css-prop,--placeholder-opacity +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 +ion-input-password-toggle,prop,hideIcon,string | undefined,undefined,false,false +ion-input-password-toggle,prop,mode,"ios" | "md",undefined,false,false +ion-input-password-toggle,prop,showIcon,string | undefined,undefined,false,false + ion-item,shadow ion-item,prop,button,boolean,false,false,false ion-item,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 2726c417393..4b85721b497 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1287,6 +1287,25 @@ export namespace Components { */ "value"?: string | number | null; } + interface IonInputPasswordToggle { + /** + * The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). + */ + "color"?: Color; + /** + * The icon that can be used to represent hiding a password. If not set, the "eyeOff" Ionicon will be used. + */ + "hideIcon"?: string; + /** + * The mode determines which platform styles to use. + */ + "mode"?: "ios" | "md"; + /** + * The icon that can be used to represent showing a password. If not set, the "eye" Ionicon will be used. + */ + "showIcon"?: string; + "type": TextFieldTypes; + } interface IonItem { /** * If `true`, a button tag will be rendered and the item will be tappable. @@ -3811,6 +3830,12 @@ declare global { prototype: HTMLIonInputElement; new (): HTMLIonInputElement; }; + interface HTMLIonInputPasswordToggleElement extends Components.IonInputPasswordToggle, HTMLStencilElement { + } + var HTMLIonInputPasswordToggleElement: { + prototype: HTMLIonInputPasswordToggleElement; + new (): HTMLIonInputPasswordToggleElement; + }; interface HTMLIonItemElement extends Components.IonItem, HTMLStencilElement { } var HTMLIonItemElement: { @@ -4635,6 +4660,7 @@ declare global { "ion-infinite-scroll": HTMLIonInfiniteScrollElement; "ion-infinite-scroll-content": HTMLIonInfiniteScrollContentElement; "ion-input": HTMLIonInputElement; + "ion-input-password-toggle": HTMLIonInputPasswordToggleElement; "ion-item": HTMLIonItemElement; "ion-item-divider": HTMLIonItemDividerElement; "ion-item-group": HTMLIonItemGroupElement; @@ -5993,6 +6019,25 @@ declare namespace LocalJSX { */ "value"?: string | number | null; } + interface IonInputPasswordToggle { + /** + * The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). + */ + "color"?: Color; + /** + * The icon that can be used to represent hiding a password. If not set, the "eyeOff" Ionicon will be used. + */ + "hideIcon"?: string; + /** + * The mode determines which platform styles to use. + */ + "mode"?: "ios" | "md"; + /** + * The icon that can be used to represent showing a password. If not set, the "eye" Ionicon will be used. + */ + "showIcon"?: string; + "type"?: TextFieldTypes; + } interface IonItem { /** * If `true`, a button tag will be rendered and the item will be tappable. @@ -8040,6 +8085,7 @@ declare namespace LocalJSX { "ion-infinite-scroll": IonInfiniteScroll; "ion-infinite-scroll-content": IonInfiniteScrollContent; "ion-input": IonInput; + "ion-input-password-toggle": IonInputPasswordToggle; "ion-item": IonItem; "ion-item-divider": IonItemDivider; "ion-item-group": IonItemGroup; @@ -8138,6 +8184,7 @@ declare module "@stencil/core" { "ion-infinite-scroll": LocalJSX.IonInfiniteScroll & JSXBase.HTMLAttributes; "ion-infinite-scroll-content": LocalJSX.IonInfiniteScrollContent & JSXBase.HTMLAttributes; "ion-input": LocalJSX.IonInput & JSXBase.HTMLAttributes; + "ion-input-password-toggle": LocalJSX.IonInputPasswordToggle & JSXBase.HTMLAttributes; "ion-item": LocalJSX.IonItem & JSXBase.HTMLAttributes; "ion-item-divider": LocalJSX.IonItemDivider & JSXBase.HTMLAttributes; "ion-item-group": LocalJSX.IonItemGroup & JSXBase.HTMLAttributes; diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-scale-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-scale-ios-ltr-Mobile-Chrome-linux.png index d838af9af88f964d8d720d8922b0d549f3498d9e..88c0d8e8f60d79d25228a0f457585ed5325c0867 100644 GIT binary patch delta 13540 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 T`wdN`|J6{_d4N%Q`uhI?_UYVq delta 13520 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 P@NcNAXe$>hK6?2-9=^ch diff --git a/core/src/components/input-password-toggle/input-password-toggle.scss b/core/src/components/input-password-toggle/input-password-toggle.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/core/src/components/input-password-toggle/input-password-toggle.tsx b/core/src/components/input-password-toggle/input-password-toggle.tsx new file mode 100644 index 00000000000..a783cfc9af3 --- /dev/null +++ b/core/src/components/input-password-toggle/input-password-toggle.tsx @@ -0,0 +1,152 @@ +import type { ComponentInterface } from '@stencil/core'; +import { Component, Element, Host, Prop, h, Watch } from '@stencil/core'; +import { printIonWarning } from '@utils/logging'; +import { createColorClasses } from '@utils/theme'; +import { eyeOff, eye } from 'ionicons/icons'; + +import { getIonMode } from '../../global/ionic-global'; +import type { Color, TextFieldTypes } from '../../interface'; + +/** + * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. + */ +@Component({ + tag: 'ion-input-password-toggle', + /** + * Empty CSS files are required in order for the mode to be inherited to the + * inner ion-button. Otherwise, the setMode callback provided to Stencil will not get called + * and we will default to MD mode. + */ + styleUrls: { + ios: 'input-password-toggle.scss', + md: 'input-password-toggle.scss', + }, + shadow: true, +}) +export class InputPasswordToggle implements ComponentInterface { + private inputElRef!: HTMLIonInputElement | null; + + @Element() el!: HTMLIonInputElement; + + /** + * The color to use from your application's color palette. + * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. + * For more information on colors, see [theming](/docs/theming/basics). + */ + @Prop({ reflect: true }) color?: Color; + + /** + * The icon that can be used to represent showing a password. If not set, the "eye" Ionicon will be used. + */ + @Prop() showIcon?: string; + + /** + * The icon that can be used to represent hiding a password. If not set, the "eyeOff" Ionicon will be used. + */ + @Prop() hideIcon?: string; + + /** + * @internal + */ + @Prop({ mutable: true }) type: TextFieldTypes = 'password'; + + /** + * Whenever the input type changes we need to re-run validation to ensure the password + * toggle is being used with the correct input type. If the application changes the type + * outside of this component we also need to re-render so the correct icon is shown. + */ + @Watch('type') + onTypeChange(newValue: TextFieldTypes) { + if (newValue !== 'text' && newValue !== 'password') { + printIonWarning( + `ion-input-password-toggle only supports inputs of type "text" or "password". Input of type "${newValue}" is not compatible.`, + this.el + ); + + return; + } + } + + connectedCallback() { + const { el } = this; + + const inputElRef = (this.inputElRef = el.closest('ion-input')); + + if (!inputElRef) { + printIonWarning( + 'No ancestor ion-input found for ion-input-password-toggle. This component must be slotted inside of an ion-input.', + el + ); + + return; + } + + /** + * Important: Set the type in connectedCallback because the default value + * of this.type may not always be accurate. Usually inputs have the "password" type + * but it is possible to have the input to initially have the "text" type. In that scenario + * the wrong icon will show briefly before switching to the correct icon. Setting the + * type here allows us to avoid that flicker. + */ + this.type = inputElRef.type; + } + + disconnectedCallback() { + this.inputElRef = null; + } + + private togglePasswordVisibility = () => { + const { inputElRef } = this; + + if (!inputElRef) { + return; + } + + inputElRef.type = inputElRef.type === 'text' ? 'password' : 'text'; + }; + + render() { + const { color, type } = this; + + const mode = getIonMode(this); + + const showPasswordIcon = this.showIcon ?? eye; + const hidePasswordIcon = this.hideIcon ?? eyeOff; + + const isPasswordVisible = type === 'text'; + + return ( + + { + /** + * This prevents mobile browsers from + * blurring the input when the password toggle + * button is activated. + */ + ev.preventDefault(); + }} + onClick={this.togglePasswordVisibility} + > + + + + ); + } +} diff --git a/core/src/components/input-password-toggle/test/a11y/input-password-toggle.e2e.ts b/core/src/components/input-password-toggle/test/a11y/input-password-toggle.e2e.ts new file mode 100644 index 00000000000..76b9f3d2837 --- /dev/null +++ b/core/src/components/input-password-toggle/test/a11y/input-password-toggle.e2e.ts @@ -0,0 +1,23 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +configs({ directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('input password toggle: a11y'), () => { + test('should not have accessibility violations', async ({ page }) => { + await page.setContent( + ` +
+ + + +
+ `, + config + ); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); + }); + }); +}); diff --git a/core/src/components/input-password-toggle/test/basic/index.html b/core/src/components/input-password-toggle/test/basic/index.html new file mode 100644 index 00000000000..e4232be7fc9 --- /dev/null +++ b/core/src/components/input-password-toggle/test/basic/index.html @@ -0,0 +1,76 @@ + + + + + Input - Toggle Password + + + + + + + + + + + + + + Input - Basic + + + + +
+
+

Default

+ + + +
+
+

Custom Icon

+ + + +
+
+

Custom Mode/Color

+ + + +
+
+
+
+ + diff --git a/core/src/components/input-password-toggle/test/basic/input-password-toggle.e2e.ts b/core/src/components/input-password-toggle/test/basic/input-password-toggle.e2e.ts new file mode 100644 index 00000000000..637c9ac3c4d --- /dev/null +++ b/core/src/components/input-password-toggle/test/basic/input-password-toggle.e2e.ts @@ -0,0 +1,50 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('input password toggle: states'), () => { + test('should be hidden when inside of a readonly input', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const inputPasswordToggle = page.locator('ion-input-password-toggle'); + await expect(inputPasswordToggle).toBeHidden(); + }); + test('should be hidden when inside of a disabled input', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const inputPasswordToggle = page.locator('ion-input-password-toggle'); + await expect(inputPasswordToggle).toBeHidden(); + }); + }); + + test.describe(title('input password toggle: rendering'), () => { + test('should not have visual regressions', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const inputPasswordToggle = page.locator('ion-input-password-toggle'); + + await expect(inputPasswordToggle).toHaveScreenshot(screenshot('input-password-toggle')); + }); + }); +}); diff --git a/core/src/components/input-password-toggle/test/basic/input-password-toggle.e2e.ts-snapshots/input-password-toggle-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/input-password-toggle/test/basic/input-password-toggle.e2e.ts-snapshots/input-password-toggle-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..4861e066bab0ad5898ac4cfcadfc5f77518ef563 GIT binary patch literal 719 zcmV;=0xPx%iAh93R9J=WmP<%fQ5b-~d*{(Sv}w$oB1NU5MV1v&K?}_uphjRsv0V|qM*4=sG<5{1YxPFf zGo3PRH49jq8{aKUNOELMWmAoZ9(O1bJH6XRRjHk;4}Em@gvie}aHz;i+w%a&uZ=~% zbJ`@jE-YmElGrmEx1^##;aLxl-i>SGy$$I!w)?1S{Sk>(6j`}^Bo*`i0ZsW=xQukx zJ8`B?xf3%G_XWB0G%!sN6*ALgnxFYK;kz$`jE5%598I@7#Fchm%xA_exn-q^#f~X$ zDnZ|{!rsjmO?cOOGe!gSdlXIFW`g2%^JY&nGhL>w#xc>sQQUs)r?SLGRf&zIX$B4! zSU9uCPE&_}TBkRkbdr}H`~N86?!NaT$i7RX{QO<{6pKV{nVka#7F-UQ;W3q4PyC!~ z@iO@R&k>DB=X1EoGV{hFB>}p+LzJHPFyxKeLWR{?CfLUt=aaBe~%% zOma5)FP>_;q$qSrQRtGQ&?QBoONv656ooD+ieDzG;53_XQj;`hBS|WFi9d}Xf>a2(D2lX*UMeU?sN$tcQ5p{-=t0q1Dp&=jYC+L} zqEd(jMNtqGF%=K3E$yM|B}#50+n*%OUlS8&YTDhj6f@}@6lP8#nf>O;Gw(M$Qh^No z<3kdVoOVc<2*2qHjRg&G(w-J0janEy+)JItDQO=-fl+Q0Sol{%3&*p9E%sQ z@9HE%U$W}z+B^O%F!|Nx)T8&54PLjQBGQT=2g5luT%15OrBXF|j5zPJpw4B$a3qHt zFOzumAw36WGfTL4Ove5#MrKfENF`RldcT564dAv7Rd{xK1=@lMj6||<)#!1v*@|;L zQ|Nx3QlUt8`>)vHtz|RH1UXK&xSkjsA*SXAkvY(5$Afn%+|mFY+G;}A5i6X{3Yn@J zr$)owPB^TZJp9Ei6J+nl41Cw4r6tMw(>*KE&_02_uWCMJ0;2?K;2@CkG*PG-XZKpr zanMrswlhI0(E{8jl#(n`N&T1Y=z5jJ<32TmjUE$e78%B-pK8|=EDAr^>41Ax`DYUo zB>n53NYi-FjF&9jy334?rseS8jpO-H20Jzyalh4yYcGv$I*r06nW!h{(#LJG=FuuUQ^7s=EM)jh7Z$rexH!Yo8`_4k^1uEe`xQ= z!`E?!4Kg4c=3jsQx2=7D{yrjt&vgl{KK`S9{Wl0V+LmKNB9B<|8@Nf5f=p*Rg`@-t zc#N>Wcbxx)Y%wDvgQg7gfvX6g?Yr_zgqKM|0AbYg_y3k3`9aWR!gkwfu_F86|9^&i zFaH`TArh2{GV7=B|6hLi2N&VxWb`#*r-I$0d`xRg_}N$xnWuZ>x7J9WXj=|RK_(w# z_UWm-J)6G$g9SJvBg4EbJ~c^Zf+iE2$Ce%Wku>e|&)-nRS?-*T;oKs;OrO60@7eUN zef`%ze;F7U7?_zD`r>)>yf}$5lZ5E(xia32eBGcGUS_b^~1 z(m)dIW?*1=`}tqnx^Hv$egF6kMHdeTW2_xVZ3wp{p|nlD-4O8o*Z;%Ue_wj|=k4cz zLcC1cGAwpltUMef7wF{JO)W4|>+ey!N9`W9d(`exyGQM&6aWC1zt}xCruAL`0000< KMNUMnLSTaXf { + it('should toggle input type when clicked', async () => { + const page = await newSpecPage({ + components: [Input, InputPasswordToggle, Button], + template: () => ( + + + + ), + }); + + const inputPasswordToggle = page.body.querySelector('ion-input-password-toggle')!; + const button = inputPasswordToggle.shadowRoot!.querySelector('ion-button')!; + const input = page.body.querySelector('ion-input')!; + + expect(input.type).toBe('password'); + + button.click(); + await page.waitForChanges(); + + expect(input.type).toBe('text'); + + button.click(); + await page.waitForChanges(); + + expect(input.type).toBe('password'); + }); + + it('should render custom icons', async () => { + const page = await newSpecPage({ + components: [Input, InputPasswordToggle, Button], + template: () => ( + + + + ), + }); + + const inputPasswordToggle = page.body.querySelector('ion-input-password-toggle')!; + const button = inputPasswordToggle.shadowRoot!.querySelector('ion-button')!; + const icon = inputPasswordToggle.shadowRoot!.querySelector('ion-icon')!; + + // Grab the attribute to test since we are not actually passing in a valid SVG + expect(icon.getAttribute('icon')).toBe('show'); + + button.click(); + await page.waitForChanges(); + + expect(icon.getAttribute('icon')).toBe('hide'); + }); + + it('changing the type on the input should update the icon used in password toggle', async () => { + const page = await newSpecPage({ + components: [Input, InputPasswordToggle, Button], + template: () => ( + + + + ), + }); + + const inputPasswordToggle = page.body.querySelector('ion-input-password-toggle')!; + const input = page.body.querySelector('ion-input')!; + const icon = inputPasswordToggle.shadowRoot!.querySelector('ion-icon')!; + + // Grab the attribute to test since we are not actually passing in a valid SVG + expect(icon.getAttribute('icon')).toBe('show'); + + input.type = 'text'; + await page.waitForChanges(); + + expect(icon.getAttribute('icon')).toBe('hide'); + + input.type = 'password'; + await page.waitForChanges(); + + expect(icon.getAttribute('icon')).toBe('show'); + }); + + it('should inherit the mode and color to internal ionic components', async () => { + /** + * This initialize script tells Stencil how to determine the mode on components. + * This is required for any getIonMode internal logic to function properly in spec tests. + */ + initialize(); + + const page = await newSpecPage({ + components: [Input, InputPasswordToggle, Button], + template: () => ( + + + + ), + }); + + const inputPasswordToggle = page.body.querySelector('ion-input-password-toggle')!; + const button = inputPasswordToggle.shadowRoot!.querySelector('ion-button')!; + + await page.waitForChanges(); + + // mode is a virtual prop so we need to access it as an attribute + expect(button.getAttribute('mode')).toBe('ios'); + + // color is an actual prop so we can access the element property + expect(button.color).toBe('danger'); + }); +}); diff --git a/core/src/components/input/input.scss b/core/src/components/input/input.scss index af3ad3c467b..80ebc9896ae 100644 --- a/core/src/components/input/input.scss +++ b/core/src/components/input/input.scss @@ -605,3 +605,12 @@ margin-inline-start: $form-control-label-margin; margin-inline-end: 0; } + +/** + * The input password toggle component should be hidden when the input is readonly/disabled + * because it is not possible to edit a password. + */ +:host([disabled]) ::slotted(ion-input-password-toggle), +:host([readonly]) ::slotted(ion-input-password-toggle) { + display: none; +} diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index eeaf63beaec..15727fe0e04 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -130,7 +130,7 @@ export class Input implements ComponentInterface { /** * If `true`, the user cannot interact with the input. */ - @Prop() disabled = false; + @Prop({ reflect: true }) disabled = false; /** * A hint to the browser for which enter key to display. @@ -226,7 +226,7 @@ export class Input implements ComponentInterface { /** * If `true`, the user cannot modify the value. */ - @Prop() readonly = false; + @Prop({ reflect: true }) readonly = false; /** * If `true`, the user must fill in a value before submitting a form. @@ -254,6 +254,20 @@ export class Input implements ComponentInterface { */ @Prop() type: TextFieldTypes = 'text'; + /** + * Whenever the type on the input changes we need + * to update the internal type prop on the password + * toggle so that that correct icon is shown. + */ + @Watch('type') + onTypeChange() { + const passwordToggle = this.el.querySelector('ion-input-password-toggle'); + + if (passwordToggle) { + passwordToggle.type = this.type; + } + } + /** * The value of the input. */ @@ -344,6 +358,14 @@ export class Input implements ComponentInterface { componentDidLoad() { this.originalIonInput = this.ionInput; + + /** + * Set the type on the password toggle in the event that this input's + * type was set async and does not match the default type for the password toggle. + * This can happen when the type is bound using a JS framework binding syntax + * such as [type] in Angular. + */ + this.onTypeChange(); } componentDidRender() { diff --git a/packages/angular/src/directives/proxies-list.ts b/packages/angular/src/directives/proxies-list.ts index 172c4ec49d7..8a0d9eb03ec 100644 --- a/packages/angular/src/directives/proxies-list.ts +++ b/packages/angular/src/directives/proxies-list.ts @@ -36,6 +36,7 @@ export const DIRECTIVES = [ d.IonInfiniteScroll, d.IonInfiniteScrollContent, d.IonInput, + d.IonInputPasswordToggle, d.IonItem, d.IonItemDivider, d.IonItemGroup, diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index d7eaecc6d81..8253b0876f2 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -1014,6 +1014,28 @@ where the user's interaction is typing. } +@ProxyCmp({ + inputs: ['color', 'hideIcon', 'mode', 'showIcon'] +}) +@Component({ + selector: 'ion-input-password-toggle', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['color', 'hideIcon', 'mode', 'showIcon'], +}) +export class IonInputPasswordToggle { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IonInputPasswordToggle extends Components.IonInputPasswordToggle {} + + @ProxyCmp({ inputs: ['button', 'color', 'detail', 'detailIcon', 'disabled', 'download', 'href', 'lines', 'mode', 'rel', 'routerAnimation', 'routerDirection', 'target', 'type'] }) diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index a677f50007c..c319aa55271 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -36,6 +36,7 @@ import { defineCustomElement as defineIonHeader } from '@ionic/core/components/i import { defineCustomElement as defineIonImg } from '@ionic/core/components/ion-img.js'; import { defineCustomElement as defineIonInfiniteScroll } from '@ionic/core/components/ion-infinite-scroll.js'; import { defineCustomElement as defineIonInfiniteScrollContent } from '@ionic/core/components/ion-infinite-scroll-content.js'; +import { defineCustomElement as defineIonInputPasswordToggle } from '@ionic/core/components/ion-input-password-toggle.js'; import { defineCustomElement as defineIonItem } from '@ionic/core/components/ion-item.js'; import { defineCustomElement as defineIonItemDivider } from '@ionic/core/components/ion-item-divider.js'; import { defineCustomElement as defineIonItemGroup } from '@ionic/core/components/ion-item-group.js'; @@ -976,6 +977,30 @@ export class IonInfiniteScrollContent { export declare interface IonInfiniteScrollContent extends Components.IonInfiniteScrollContent {} +@ProxyCmp({ + defineCustomElementFn: defineIonInputPasswordToggle, + inputs: ['color', 'hideIcon', 'mode', 'showIcon'] +}) +@Component({ + selector: 'ion-input-password-toggle', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['color', 'hideIcon', 'mode', 'showIcon'], + standalone: true +}) +export class IonInputPasswordToggle { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IonInputPasswordToggle extends Components.IonInputPasswordToggle {} + + @ProxyCmp({ defineCustomElementFn: defineIonItem, inputs: ['button', 'color', 'detail', 'detailIcon', 'disabled', 'download', 'href', 'lines', 'mode', 'rel', 'routerAnimation', 'routerDirection', 'target', 'type'] diff --git a/packages/react/src/components/proxies.ts b/packages/react/src/components/proxies.ts index 54e5d4f605a..05800f38773 100644 --- a/packages/react/src/components/proxies.ts +++ b/packages/react/src/components/proxies.ts @@ -31,6 +31,7 @@ import { defineCustomElement as defineIonImg } from '@ionic/core/components/ion- import { defineCustomElement as defineIonInfiniteScroll } from '@ionic/core/components/ion-infinite-scroll.js'; import { defineCustomElement as defineIonInfiniteScrollContent } from '@ionic/core/components/ion-infinite-scroll-content.js'; import { defineCustomElement as defineIonInput } from '@ionic/core/components/ion-input.js'; +import { defineCustomElement as defineIonInputPasswordToggle } from '@ionic/core/components/ion-input-password-toggle.js'; import { defineCustomElement as defineIonItemDivider } from '@ionic/core/components/ion-item-divider.js'; import { defineCustomElement as defineIonItemGroup } from '@ionic/core/components/ion-item-group.js'; import { defineCustomElement as defineIonItemOptions } from '@ionic/core/components/ion-item-options.js'; @@ -99,6 +100,7 @@ export const IonImg = /*@__PURE__*/createReactComponent('ion-infinite-scroll', undefined, undefined, defineIonInfiniteScroll); export const IonInfiniteScrollContent = /*@__PURE__*/createReactComponent('ion-infinite-scroll-content', undefined, undefined, defineIonInfiniteScrollContent); export const IonInput = /*@__PURE__*/createReactComponent('ion-input', undefined, undefined, defineIonInput); +export const IonInputPasswordToggle = /*@__PURE__*/createReactComponent('ion-input-password-toggle', undefined, undefined, defineIonInputPasswordToggle); export const IonItemDivider = /*@__PURE__*/createReactComponent('ion-item-divider', undefined, undefined, defineIonItemDivider); export const IonItemGroup = /*@__PURE__*/createReactComponent('ion-item-group', undefined, undefined, defineIonItemGroup); export const IonItemOptions = /*@__PURE__*/createReactComponent('ion-item-options', undefined, undefined, defineIonItemOptions); diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index eab6fe4a361..30d640d44f2 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -35,6 +35,7 @@ import { defineCustomElement as defineIonImg } from '@ionic/core/components/ion- import { defineCustomElement as defineIonInfiniteScroll } from '@ionic/core/components/ion-infinite-scroll.js'; import { defineCustomElement as defineIonInfiniteScrollContent } from '@ionic/core/components/ion-infinite-scroll-content.js'; import { defineCustomElement as defineIonInput } from '@ionic/core/components/ion-input.js'; +import { defineCustomElement as defineIonInputPasswordToggle } from '@ionic/core/components/ion-input-password-toggle.js'; import { defineCustomElement as defineIonItem } from '@ionic/core/components/ion-item.js'; import { defineCustomElement as defineIonItemDivider } from '@ionic/core/components/ion-item-divider.js'; import { defineCustomElement as defineIonItemGroup } from '@ionic/core/components/ion-item-group.js'; @@ -436,6 +437,14 @@ export const IonInput = /*@__PURE__*/ defineContainer('ion-input-password-toggle', defineIonInputPasswordToggle, [ + 'color', + 'showIcon', + 'hideIcon', + 'type' +]); + + export const IonItem = /*@__PURE__*/ defineContainer('ion-item', defineIonItem, [ 'color', 'button',