Repository: mlaursen/react-md
Pull request #864 (2020-02-17 12:28:07)

Allow TextField passwordIcon prop to accept different elements based on visibility
Across a few projects using this library, I implemented very similar login forms and am using SVG icons with the **[@mdi/react](https://github.com/Templarian/MaterialDesign-React)** and **[@mdi/js](https://github.com/Templarian/MaterialDesign-JS)** libraries.  the `passwordIcon` prop of **TextField**.  I realized when putting just one icon element it wasn't representing the visibility state accurately.  While there are two pseudoelements which draw a white line through the default eye icon, they don't stay there when the input loses focus.  Furthermore, the white line assumes the designer would want the icon to look that way rather than allowing for more creative freedom.

Working within the boundaries of the library, I initially tried to handle visibility state outside of the TextField with click handlers on the icons; however, [the maintainer of the mdi libraries corrected my mistake](https://github.com/Templarian/MaterialDesign-React/issues/36) telling this would not be ideal for accessibility.  So I spent a good part of the day today tackling this issue to find a better solution.

What you'll find in this PR is an additional type for `passwordIcon` which accepts an object with two keys, `invisible` and `visible`.  The key values must be an element like the original prop.  What this does is simply switches the icon element based on the visibility of the password TextField.  In addition to that, I added a `doubleIcon` boolean prop to `PasswordButton` to remove the white line pseudoelement, since I imagine anyone using this feature wouldn't want the white line to be there.

I also made sure this runs and shows nicely in the documentation, and added a new example for it as well as tests.  This prototype seems to be working well in a NextJS Typescript app I am developing.

mlaursen: Yeah, that makes a lot of sense -- I'll make sure this gets ported forward to v2 as well. I definitely made the password field too restrictive.

Thanks for the great description along with this PR! Looks good to me, and I'll release it as v1.15.0 tonight.

Patch:
diff --git a/docs/src/components/Components/TextFields/SeparateIcons.jsx b/docs/src/components/Components/TextFields/SeparateIcons.jsx
new file mode 100644
index 0000000000..c69df89cc1
--- /dev/null
+++ b/docs/src/components/Components/TextFields/SeparateIcons.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import {
+  FontIcon,
+  SVGIcon,
+  TextField,
+} from 'react-md';
+
+import invisible from 'icons/visibility_off.svg';
+import visible from 'icons/visibility.svg';
+import locked from 'icons/lock.svg';
+import unlocked from 'icons/lock_open.svg';
+
+const SeparateIcons = () => (
+  <div className="md-grid text-fields">
+    <TextField
+      className="md-cell md-cell--bottom"
+      id="password-field-two-icons-with-font-icon"
+      label="Password with FontIcon"
+      passwordIcon={{
+        invisible: <FontIcon>visibility_off</FontIcon>,
+        visible: <FontIcon>visibility</FontIcon>,
+      }}
+      type="password"
+    />
+    <TextField
+      className="md-cell md-cell--bottom"
+      id="password-field-two-icons-with-svg-icon"
+      label="Password with SVG"
+      passwordIcon={{
+        invisible: <SVGIcon use={invisible.url} />,
+        visible: <SVGIcon use={visible.url} />,
+      }}
+      type="password"
+    />
+    <TextField
+      className="md-cell md-cell--bottom"
+      id="password-field-two-icons-with-alt-svg-icon"
+      label="Password with Alternate SVG"
+      passwordIcon={{
+        invisible: <SVGIcon use={locked.url} />,
+        visible: <SVGIcon use={unlocked.url} />,
+      }}
+      type="password"
+    />
+  </div>
+);
+export default SeparateIcons;
diff --git a/docs/src/components/Components/TextFields/index.jsx b/docs/src/components/Components/TextFields/index.jsx
index 80d3334cdf..56f05f73d6 100644
--- a/docs/src/components/Components/TextFields/index.jsx
+++ b/docs/src/components/Components/TextFields/index.jsx
@@ -18,6 +18,8 @@ import BlockedFields from './BlockedFields';
 import BlockedFieldsRaw from '!!raw-loader!./BlockedFields.jsx';
 import FormExample from './FormExample';
 import FormExampleRaw from '!!raw-loader!./FormExample.jsx';
+import SeparateIcons from './SeparateIcons.jsx';
+import SeparateIconsRaw from '!!raw-loader!./SeparateIcons.jsx';
 
 const examples = [{
   title: 'Floating Label Text Fields',
@@ -56,6 +58,16 @@ toggle button.
   `,
   code: InlineIconsRaw,
   children: <InlineIcons />,
+}, {
+  title: 'Separate Icons for Password Visibility',
+  description: `
+The \`passwordIcon\` prop may alternatively be supplied two separate icons by setting the prop as an object.
+It should have two keys, \`invisible\` and \`visible\`, with an element for each key's value.
+
+When password is visible, the visible icon will load, and likewise the invisible icon will load when password is not visible.
+`,
+  code: SeparateIconsRaw,
+  children: <SeparateIcons />,
 }, {
   title: 'Disabled Text Fields',
   description: `
diff --git a/src/js/TextFields/PasswordButton.js b/src/js/TextFields/PasswordButton.js
index 0bab668697..ee7a342066 100644
--- a/src/js/TextFields/PasswordButton.js
+++ b/src/js/TextFields/PasswordButton.js
@@ -8,6 +8,7 @@ import themeColors from '../utils/themeColors';
 export default class PasswordButton extends PureComponent {
   static propTypes = {
     active: PropTypes.bool,
+    doubleIcon: PropTypes.bool,
     passwordVisible: PropTypes.bool,
     icon: PropTypes.element,
     block: PropTypes.bool,
@@ -40,6 +41,7 @@ export default class PasswordButton extends PureComponent {
     const { keyboardFocus } = this.state;
     const {
       active,
+      doubleIcon,
       passwordVisible,
       block,
       floating,
@@ -55,7 +57,8 @@ export default class PasswordButton extends PureComponent {
         type="button"
         className={cn('md-text-field-inline-indicator md-password-btn md-pointer--hover', {
           'md-password-btn--focus': keyboardFocus,
-          'md-password-btn--invisible': active && !passwordVisible,
+          'md-password-btn--invisible':
+            !doubleIcon && (active && !passwordVisible),
           'md-text-field-inline-indicator--floating': floating,
           'md-text-field-inline-indicator--block': block,
         }, themeColors({ disabled: !active, hint: active }))}
diff --git a/src/js/TextFields/TextField.d.ts b/src/js/TextFields/TextField.d.ts
index 5be2c76c77..550d0e39c5 100644
--- a/src/js/TextFields/TextField.d.ts
+++ b/src/js/TextFields/TextField.d.ts
@@ -23,7 +23,10 @@ export interface SharedTextFieldProps {
   leftIconStateful?: boolean;
   rightIcon?: React.ReactElement<any>;
   rightIconStateful?: boolean;
-  passwordIcon?: React.ReactElement<any>;
+  passwordIcon?: React.ReactElement<any> | {
+    invisible: React.ReactElement<any>;
+    visible: React.ReactElement<any>;
+  };
   passwordInitiallyVisible?: boolean;
   fullWidth?: boolean;
   rows?: number;
diff --git a/src/js/TextFields/TextField.js b/src/js/TextFields/TextField.js
index c815d27ca5..1884530089 100644
--- a/src/js/TextFields/TextField.js
+++ b/src/js/TextFields/TextField.js
@@ -274,8 +274,17 @@ export default class TextField extends PureComponent {
 
     /**
      * The icon to use for a password text field.
-     */
-    passwordIcon: PropTypes.element,
+     *
+     * Alternatively, two separate elements may be used to change between
+     * them based on the value of the `passwordVisible` state variable.
+     */
+    passwordIcon: PropTypes.oneOfType([
+      PropTypes.element,
+      PropTypes.shape({
+        invisible: PropTypes.element,
+        visible: PropTypes.element,
+      }),
+    ]),
 
     /**
      * Boolean if the password is initially visible.
@@ -779,13 +788,21 @@ export default class TextField extends PureComponent {
     leftIcon = this._cloneIcon(icon || leftIcon, active, error, disabled, leftIconStateful, block, 'left');
     const passwordIcon = getDeprecatedIcon(passwordIconClassName, passwordIconChildren, propPasswordIcon);
     if (passwordIcon !== null && type === 'password' && !disabled) {
+      let passwordIconProp = passwordIcon;
+      let doubleIcon = false;
+      if (passwordIcon.invisible && passwordIcon.visible) {
+        passwordIconProp = passwordVisible
+          ? passwordIcon.visible : passwordIcon.invisible;
+        doubleIcon = true;
+      }
       rightIcon = (
         <PasswordButton
           key="password-btn"
           onClick={this._togglePasswordField}
           active={active}
+          doubleIcon={doubleIcon}
           passwordVisible={passwordVisible}
-          icon={passwordIcon}
+          icon={passwordIconProp}
           block={block}
           floating={!!label}
         />


Test patch:
diff --git a/docs/src/components/Components/TextFields/__tests__/SeparateIcons.jsx b/docs/src/components/Components/TextFields/__tests__/SeparateIcons.jsx
new file mode 100644
index 0000000000..5b5739165f
--- /dev/null
+++ b/docs/src/components/Components/TextFields/__tests__/SeparateIcons.jsx
@@ -0,0 +1,12 @@
+/* eslint-env jest */
+import React from 'react';
+import { render } from 'enzyme';
+
+import SeparateIcons from '../SeparateIcons';
+
+describe('SeparateIcons', () => {
+  it('should render correctly', () => {
+    const tree = render(<SeparateIcons />);
+    expect(tree).toMatchSnapshot();
+  });
+});
diff --git a/docs/src/components/Components/TextFields/__tests__/__snapshots__/SeparateIcons.jsx.snap b/docs/src/components/Components/TextFields/__tests__/__snapshots__/SeparateIcons.jsx.snap
new file mode 100644
index 0000000000..3af449abd4
--- /dev/null
+++ b/docs/src/components/Components/TextFields/__tests__/__snapshots__/SeparateIcons.jsx.snap
@@ -0,0 +1,96 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SeparateIcons should render correctly 1`] = `
+<div
+  class="md-grid text-fields"
+>
+  <div
+    class="md-text-field-container md-full-width md-text-field-container--input md-cell md-cell--bottom"
+  >
+    <label
+      class="md-floating-label md-floating-label--inactive md-floating-label--inactive-sized md-text--secondary"
+      for="password-field-two-icons-with-font-icon"
+    >
+      Password with FontIcon
+    </label>
+    <input
+      class="md-text-field md-text-field--inline-indicator md-text-field--floating-margin md-full-width md-text"
+      id="password-field-two-icons-with-font-icon"
+      type="password"
+    />
+    <hr
+      class="md-divider md-divider--text-field md-divider--expand-from-left"
+    />
+    <button
+      class="md-text-field-inline-indicator md-password-btn md-pointer--hover md-text-field-inline-indicator--floating md-text--disabled"
+      type="button"
+    >
+      <i
+        class="md-icon material-icons"
+      >
+        visibility_off
+      </i>
+    </button>
+  </div>
+  <div
+    class="md-text-field-container md-full-width md-text-field-container--input md-cell md-cell--bottom"
+  >
+    <label
+      class="md-floating-label md-floating-label--inactive md-floating-label--inactive-sized md-text--secondary"
+      for="password-field-two-icons-with-svg-icon"
+    >
+      Password with SVG
+    </label>
+    <input
+      class="md-text-field md-text-field--inline-indicator md-text-field--floating-margin md-full-width md-text"
+      id="password-field-two-icons-with-svg-icon"
+      type="password"
+    />
+    <hr
+      class="md-divider md-divider--text-field md-divider--expand-from-left"
+    />
+    <button
+      class="md-text-field-inline-indicator md-password-btn md-pointer--hover md-text-field-inline-indicator--floating md-text--disabled"
+      type="button"
+    >
+      <svg
+        class="md-icon"
+        focusable="false"
+        role="img"
+        viewBox="0 0 24 24"
+        xmlns="http://www.w3.org/2000/svg"
+      />
+    </button>
+  </div>
+  <div
+    class="md-text-field-container md-full-width md-text-field-container--input md-cell md-cell--bottom"
+  >
+    <label
+      class="md-floating-label md-floating-label--inactive md-floating-label--inactive-sized md-text--secondary"
+      for="password-field-two-icons-with-alt-svg-icon"
+    >
+      Password with Alternate SVG
+    </label>
+    <input
+      class="md-text-field md-text-field--inline-indicator md-text-field--floating-margin md-full-width md-text"
+      id="password-field-two-icons-with-alt-svg-icon"
+      type="password"
+    />
+    <hr
+      class="md-divider md-divider--text-field md-divider--expand-from-left"
+    />
+    <button
+      class="md-text-field-inline-indicator md-password-btn md-pointer--hover md-text-field-inline-indicator--floating md-text--disabled"
+      type="button"
+    >
+      <svg
+        class="md-icon"
+        focusable="false"
+        role="img"
+        viewBox="0 0 24 24"
+        xmlns="http://www.w3.org/2000/svg"
+      />
+    </button>
+  </div>
+</div>
+`;
diff --git a/src/js/TextFields/__tests__/PasswordButton.js b/src/js/TextFields/__tests__/PasswordButton.js
index ccb712d4fb..0ec398dfa5 100644
--- a/src/js/TextFields/__tests__/PasswordButton.js
+++ b/src/js/TextFields/__tests__/PasswordButton.js
@@ -22,6 +22,9 @@ describe('PasswordButton', () => {
 
     button.setProps({ floating: false, block: true });
     expect(button.render()).toMatchSnapshot();
+
+    button.setProps({ doubleIcon: true });
+    expect(button.render()).toMatchSnapshot();
   });
 
   it('should update the keyboardFocus state when the TAB key is pressed', () => {
diff --git a/src/js/TextFields/__tests__/TextField.js b/src/js/TextFields/__tests__/TextField.js
index 1b28236ceb..ee1d291de4 100644
--- a/src/js/TextFields/__tests__/TextField.js
+++ b/src/js/TextFields/__tests__/TextField.js
@@ -402,6 +402,20 @@ describe('TextField', () => {
     expect(field.find(PasswordButton).length).toBe(0);
   });
 
+  it('should render two different elements if password icon has invisible and visible keys', () => {
+    const field = mount(
+      <TextField
+        id="test-field"
+        passwordIcon={{
+          invisible: <FontIcon>visibility_off</FontIcon>,
+          visible: <FontIcon>visibility</FontIcon>,
+        }}
+        type="password"
+      />
+    );
+    expect(field.find(PasswordButton).length).toBe(1);
+  });
+
   describe('resizing', () => {
     beforeEach(() => {
       getTextWidth.mockClear();
diff --git a/src/js/TextFields/__tests__/__snapshots__/PasswordButton.js.snap b/src/js/TextFields/__tests__/__snapshots__/PasswordButton.js.snap
index e4b0570be2..706ad711b4 100644
--- a/src/js/TextFields/__tests__/__snapshots__/PasswordButton.js.snap
+++ b/src/js/TextFields/__tests__/__snapshots__/PasswordButton.js.snap
@@ -64,3 +64,16 @@ exports[`PasswordButton should render correctly 5`] = `
   </i>
 </button>
 `;
+
+exports[`PasswordButton should render correctly 6`] = `
+<button
+  class="md-text-field-inline-indicator md-password-btn md-pointer--hover md-text-field-inline-indicator--block md-text--secondary"
+  type="button"
+>
+  <i
+    class="md-icon material-icons"
+  >
+    remove_red_eye
+  </i>
+</button>
+`;