diff --git a/CHANGELOG.md b/CHANGELOG.md index bd1e9f50fbc..1f63a40be8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Added possibility to hide "Rows per page" select in `EuiDataGrid` ([#3700](https://github.com/elastic/eui/pull/3700)) - Updated lodash to `v4.17.19` ([#3764](https://github.com/elastic/eui/pull/3764)) - Added `returnKey` glyph to `EuiIcon` ([#3783](https://github.com/elastic/eui/pull/3783)) +- Added `type` prop to `EuiFieldPassword` to support toggling of obfuscation ([#3751](https://github.com/elastic/eui/pull/3751)) **Bug fixes** diff --git a/src-docs/src/views/datagrid/styling.js b/src-docs/src/views/datagrid/styling.js index 982535a6ad4..c719acf0948 100644 --- a/src-docs/src/views/datagrid/styling.js +++ b/src-docs/src/views/datagrid/styling.js @@ -428,7 +428,6 @@ export default class DataGrid extends Component { button={styleButton} isOpen={this.state.isPopoverOpen} anchorPosition="rightUp" - zIndex={3} closePopover={this.closePopover.bind(this)}>
@@ -505,7 +504,6 @@ export default class DataGrid extends Component { button={toolbarButton} isOpen={this.state.isToolbarPopoverOpen} anchorPosition="rightUp" - zIndex={3} closePopover={this.closeToolbarPopover.bind(this)}>
{ - setValue(e.target.value); - }; + const [dual, setDual] = useState(true); return ( /* DisplayToggles wrapper for Docs only */ - + { + setDual(e.target.checked); + }} + />, + ]}> onChange(e)} + onChange={e => setValue(e.target.value)} aria-label="Use aria labels when no actual label is in use" /> diff --git a/src-docs/src/views/form_controls/form_controls_example.js b/src-docs/src/views/form_controls/form_controls_example.js index a533ffc8d02..d0f0aa27048 100644 --- a/src-docs/src/views/form_controls/form_controls_example.js +++ b/src-docs/src/views/form_controls/form_controls_example.js @@ -72,6 +72,7 @@ const fieldPasswordSnippet = [ placeholder="Placeholder text" value={value} onChange={onChange} + type="dual" />`, ]; diff --git a/src/components/form/field_password/__snapshots__/field_password.test.tsx.snap b/src/components/form/field_password/__snapshots__/field_password.test.tsx.snap index e9e19a0bd11..91597c248c9 100644 --- a/src/components/form/field_password/__snapshots__/field_password.test.tsx.snap +++ b/src/components/form/field_password/__snapshots__/field_password.test.tsx.snap @@ -1,91 +1,403 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`EuiFieldPassword is rendered 1`] = ` - - - + + + +
+ + +
+
+`; + +exports[`EuiFieldPassword props compressed is rendered 1`] = ` +
+
+ + + +
+ + +
+
+`; + +exports[`EuiFieldPassword props dual type also renders append 1`] = ` +
+
+ + + +
+ + +
+ + + Span + + +
`; -exports[`EuiFieldPassword props fullWidth is rendered 1`] = ` - - - + + + +
+ + +
+ +
+`; + +exports[`EuiFieldPassword props fullWidth is rendered 1`] = ` +
+
+ + + +
+ + +
+
`; exports[`EuiFieldPassword props isInvalid is rendered 1`] = ` - - - - - + + + +
+ + +
+
`; exports[`EuiFieldPassword props isLoading is rendered 1`] = ` - - - - - +
+ + + +
+ + +
+ +
+
+
`; exports[`EuiFieldPassword props prepend and append is rendered 1`] = ` - + +
+ + + +
+ + +
+ +
+`; + +exports[`EuiFieldPassword props type dual is rendered 1`] = ` +
- - + + + +
+ + +
+ +
+`; + +exports[`EuiFieldPassword props type password is rendered 1`] = ` +
+
+ + + +
+ + +
+
+`; + +exports[`EuiFieldPassword props type text is rendered 1`] = ` +
+
+ + + +
+ + +
+
`; diff --git a/src/components/form/field_password/_field_password.scss b/src/components/form/field_password/_field_password.scss index 00f724ea2b3..17ea2e5277b 100644 --- a/src/components/form/field_password/_field_password.scss +++ b/src/components/form/field_password/_field_password.scss @@ -7,3 +7,9 @@ @include euiFormControlWithIcon($isIconOptional: false, $side: 'left', $compressed: true); } } + +// sass-lint:disable-block no-vendor-prefixes +// Only remove Edge's inernal reveal button if we're providing a custom one +.euiFieldPassword--withToggle::-ms-reveal { + display: none; +} diff --git a/src/components/form/field_password/field_password.test.tsx b/src/components/form/field_password/field_password.test.tsx index 541b694a6c5..31bd3479f53 100644 --- a/src/components/form/field_password/field_password.test.tsx +++ b/src/components/form/field_password/field_password.test.tsx @@ -21,15 +21,18 @@ import React from 'react'; import { render } from 'enzyme'; import { requiredProps } from '../../../test/required_props'; -import { EuiFieldPassword } from './field_password'; +import { EuiFieldPassword, EuiFieldPasswordProps } from './field_password'; -jest.mock('../form_control_layout', () => ({ - EuiFormControlLayout: 'eui-form-control-layout', -})); jest.mock('../validatable_control', () => ({ EuiValidatableControl: 'eui-validatable-control', })); +const TYPES: Array = [ + 'password', + 'text', + 'dual', +]; + describe('EuiFieldPassword', () => { test('is rendered', () => { const component = render( @@ -72,5 +75,37 @@ describe('EuiFieldPassword', () => { expect(component).toMatchSnapshot(); }); + + test('compressed is rendered', () => { + const component = render(); + + expect(component).toMatchSnapshot(); + }); + + describe('type', () => { + TYPES.forEach(type => { + test(`${type} is rendered`, () => { + const component = render(); + + expect(component).toMatchSnapshot(); + }); + }); + }); + + test('dualToggleProps is rendered', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('dual type also renders append', () => { + const component = render( + Span]} /> + ); + + expect(component).toMatchSnapshot(); + }); }); }); diff --git a/src/components/form/field_password/field_password.tsx b/src/components/form/field_password/field_password.tsx index 9408ecfb7fa..6d1c02edb7e 100644 --- a/src/components/form/field_password/field_password.tsx +++ b/src/components/form/field_password/field_password.tsx @@ -17,7 +17,12 @@ * under the License. */ -import React, { InputHTMLAttributes, Ref, FunctionComponent } from 'react'; +import React, { + InputHTMLAttributes, + FunctionComponent, + useState, + Ref, +} from 'react'; import { CommonProps } from '../../common'; import classNames from 'classnames'; @@ -27,6 +32,9 @@ import { } from '../form_control_layout'; import { EuiValidatableControl } from '../validatable_control'; +import { EuiButtonIcon, EuiButtonIconProps } from '../../button'; +import { useEuiI18n } from '../../i18n'; +import { useCombinedRefs } from '../../../services'; export type EuiFieldPasswordProps = InputHTMLAttributes & CommonProps & { @@ -47,6 +55,18 @@ export type EuiFieldPasswordProps = InputHTMLAttributes & * `string` | `ReactElement` or an array of these */ append?: EuiFormControlLayoutProps['append']; + + /** + * Change the `type` of input for manually handling obfuscation. + * The `dual` option adds the ability to toggle the obfuscation of the input by + * adding an icon button as the first `append` element + */ + type?: 'password' | 'text' | 'dual'; + + /** + * Additional props to apply to the dual toggle. Extends EuiButtonIcon + */ + dualToggleProps?: EuiButtonIconProps; }; export const EuiFieldPassword: FunctionComponent = ({ @@ -59,18 +79,70 @@ export const EuiFieldPassword: FunctionComponent = ({ fullWidth, isLoading, compressed, - inputRef, + inputRef: _inputRef, prepend, append, + type = 'password', + dualToggleProps, ...rest }) => { + // Set the initial input type to `password` if they want dual + const [inputType, setInputType] = useState( + type === 'dual' ? 'password' : type + ); + + // Setup toggle aria-label + const [showPasswordLabel, maskPasswordLabel] = useEuiI18n( + ['euiFieldPassword.showPassword', 'euiFieldPassword.maskPassword'], + [ + 'Show password as plain text. Note: this will visually expose your password on the screen.', + 'Mask password', + ] + ); + + // Setup the inputRef to auto-focus when toggling visibility + const [inputRef, _setInputRef] = useState(null); + const setInputRef = useCombinedRefs([_setInputRef, _inputRef]); + + const handleToggle = (isVisible: boolean) => { + setInputType(isVisible ? 'password' : 'text'); + if (inputRef) { + inputRef.focus(); + } + }; + + // Convert any `append` elements to an array so the visibility + // toggle can be added to it + const appends = Array.isArray(append) ? append : []; + if (append && !Array.isArray(append)) appends.push(append); + // Add a toggling button to switch between `password` and `input` if consumer wants `dual` + // https://www.w3schools.com/howto/howto_js_toggle_password.asp + if (type === 'dual') { + const isVisible = inputType === 'text'; + + const visibilityToggle = ( + handleToggle(isVisible)} + aria-label={isVisible ? maskPasswordLabel : showPasswordLabel} + title={isVisible ? maskPasswordLabel : showPasswordLabel} + disabled={rest.disabled} + /> + ); + appends.push(visibilityToggle); + } + + const finalAppend = appends.length ? appends : undefined; + const classes = classNames( 'euiFieldPassword', { 'euiFieldPassword--fullWidth': fullWidth, 'euiFieldPassword--compressed': compressed, 'euiFieldPassword-isLoading': isLoading, - 'euiFieldPassword--inGroup': prepend || append, + 'euiFieldPassword--inGroup': prepend || finalAppend, + 'euiFieldPassword--withToggle': type === 'dual', }, className ); @@ -82,16 +154,16 @@ export const EuiFieldPassword: FunctionComponent = ({ isLoading={isLoading} compressed={compressed} prepend={prepend} - append={append}> + append={finalAppend}> diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts index 94741454012..7a7b08a4b42 100644 --- a/src/services/hooks/index.ts +++ b/src/services/hooks/index.ts @@ -17,4 +17,5 @@ * under the License. */ +export * from './useCombinedRefs'; export * from './useDependentState'; diff --git a/src/services/hooks/useCombinedRefs.ts b/src/services/hooks/useCombinedRefs.ts new file mode 100644 index 00000000000..244df11e92a --- /dev/null +++ b/src/services/hooks/useCombinedRefs.ts @@ -0,0 +1,45 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { MutableRefObject, Ref, useCallback } from 'react'; + +/* + * For use when a component needs to set `ref` objects from multiple sources. + * For instance, if a component accepts a `ref` prop but also needs its own + * local reference for calculations, etc. + * This hook handles setting multiple `ref`s of any available `ref` type + * in a single callback function. + */ +export const useCombinedRefs = ( + refs: Array | MutableRefObject | undefined> +) => { + return useCallback( + (node: T) => + refs.forEach(ref => { + if (!ref) return; + + if (typeof ref === 'function') { + ref(node); + } else { + (ref as MutableRefObject).current = node; + } + }), + [refs] + ); +}; diff --git a/src/services/index.ts b/src/services/index.ts index 7409ce75601..4ca4aef8ab3 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -108,4 +108,4 @@ export { export { EuiWindowEvent } from './window_event'; -export { useDependentState } from './hooks'; +export { useCombinedRefs, useDependentState } from './hooks';