diff --git a/src/components/text-input-with-copy-button/TextInputWithCopyButton.js b/src/components/text-input-with-copy-button/TextInputWithCopyButton.js.flow similarity index 100% rename from src/components/text-input-with-copy-button/TextInputWithCopyButton.js rename to src/components/text-input-with-copy-button/TextInputWithCopyButton.js.flow diff --git a/src/components/text-input-with-copy-button/TextInputWithCopyButton.stories.js b/src/components/text-input-with-copy-button/TextInputWithCopyButton.stories.tsx similarity index 98% rename from src/components/text-input-with-copy-button/TextInputWithCopyButton.stories.js rename to src/components/text-input-with-copy-button/TextInputWithCopyButton.stories.tsx index 19b505bf7b..05125169de 100644 --- a/src/components/text-input-with-copy-button/TextInputWithCopyButton.stories.js +++ b/src/components/text-input-with-copy-button/TextInputWithCopyButton.stories.tsx @@ -1,4 +1,3 @@ -// @flow import * as React from 'react'; import TextInputWithCopyButton from './TextInputWithCopyButton'; diff --git a/src/components/text-input-with-copy-button/TextInputWithCopyButton.tsx b/src/components/text-input-with-copy-button/TextInputWithCopyButton.tsx new file mode 100644 index 0000000000..4d09a45c73 --- /dev/null +++ b/src/components/text-input-with-copy-button/TextInputWithCopyButton.tsx @@ -0,0 +1,244 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import omit from 'lodash/omit'; +import { FormattedMessage } from 'react-intl'; + +import messages from '../../common/messages'; +import TextInput, { type TextInputProps } from '../text-input'; +import Button, { ButtonType } from '../button'; + +import './TextInputWithCopyButton.scss'; + +const DEFAULT_SUCCESS_STATE_DURATION = 3000; + +const defaultCopyText = ; +const defaultCopiedText = ; + +export interface TextInputWithCopyButtonProps + extends Omit { + /** Array of nodes for additional buttons */ + additionalButtons?: React.ReactNode[]; + /** Set the focus to input when component loads */ + autofocus?: boolean; + /** Default copy button text */ + buttonDefaultText: React.ReactNode; + /** Props passed to the copy button */ + buttonProps?: Record; + /** Copy button text when copy is successful */ + buttonSuccessText?: React.ReactNode; + /** Custom class for the component */ + className: string; + /** Disables the text input and copy button */ + disabled?: boolean; + /** Label displayed for the text input */ + // TODO: Make label required + label?: React.ReactNode; + /** Function called when link is copied by keyboard or button */ + onCopySuccess?: (event: React.SyntheticEvent) => void; + /** Focus handler for the input element */ + onFocus?: (event: React.SyntheticEvent) => void; + /** Duration (milliseconds) in which to show the copy success state */ + successStateDuration: number; + /** Triggers the copy animation when the component loads */ + triggerCopyOnLoad?: boolean; + /** HTML input type, defaults to "text" */ + type: string; + /** Value of the text input */ + value: React.ReactNode; +} + +interface State { + /** Text currently displayed by the copy button */ + buttonText: React.ReactNode; + /** Whether the copy action succeeded */ + copySuccess: boolean; + /** Whether the input has already been focused */ + hasFocused: boolean; +} + +class TextInputWithCopyButton extends React.PureComponent { + static defaultProps = { + buttonDefaultText: defaultCopyText, + buttonProps: {}, + buttonSuccessText: defaultCopiedText, + className: '', + hideOptionalLabel: true, + readOnly: true, + successStateDuration: DEFAULT_SUCCESS_STATE_DURATION, + type: 'text', + }; + + constructor(props: TextInputWithCopyButtonProps) { + super(props); + + this.isCopyCommandSupported = document.queryCommandSupported('copy'); + + this.state = { + copySuccess: false, + buttonText: props.buttonDefaultText, + hasFocused: false, + }; + } + + componentDidMount() { + const { autofocus, value } = this.props; + + if (autofocus && value) { + this.performAutofocus(); + } + } + + componentDidUpdate() { + const { autofocus, value, triggerCopyOnLoad } = this.props; + const { copySuccess, hasFocused } = this.state; + + // if we've set focus before, and should auto focus on update, make sure to + // focus after component update + if (autofocus && value) { + this.performAutofocus(); + } + + if (triggerCopyOnLoad && !copySuccess && !hasFocused) { + this.animateCopyButton(); + } + } + + componentWillUnmount() { + this.clearCopySuccessTimeout(); + } + + copyInputRef: HTMLInputElement | null = null; + + copySuccessTimeout: number | null = null; + + isCopyCommandSupported: boolean; + + animateCopyButton() { + const { successStateDuration, buttonSuccessText } = this.props; + this.clearCopySuccessTimeout(); + + this.setState( + { + copySuccess: true, + buttonText: buttonSuccessText, + hasFocused: true, + }, + () => { + this.copySuccessTimeout = window.setTimeout(() => { + this.restoreCopyButton(); + }, successStateDuration); + }, + ); + } + + clearCopySuccessTimeout() { + if (!this.copySuccessTimeout) { + return; + } + + window.clearTimeout(this.copySuccessTimeout); + this.copySuccessTimeout = null; + } + + copySelectedText = () => document.execCommand('copy'); + + restoreCopyButton = () => { + this.setState({ + copySuccess: false, + buttonText: this.props.buttonDefaultText, + }); + }; + + handleCopyButtonClick = () => { + this.performAutofocus(); + this.copySelectedText(); + this.animateCopyButton(); + }; + + handleFocus = (event: React.SyntheticEvent) => { + if (this.copyInputRef) { + this.performAutofocus(); + } + + if (this.props.onFocus) { + this.props.onFocus(event); + } + }; + + handleCopyEvent = (event: React.SyntheticEvent) => { + const { disabled, onCopySuccess } = this.props; + + if (disabled) { + event.preventDefault(); + } else { + this.animateCopyButton(); + + if (onCopySuccess) { + onCopySuccess(event); + } + } + }; + + performAutofocus = () => { + const { copyInputRef } = this; + if (copyInputRef) { + copyInputRef.select(); + copyInputRef.scrollLeft = 0; + } + }; + + renderCopyButton = () => + this.isCopyCommandSupported ? ( + + ) : null; + + render() { + const { additionalButtons, className, ...rest } = this.props; + const { copySuccess } = this.state; + const { isCopyCommandSupported } = this; + + const inputProps = omit(rest, [ + 'autofocus', + 'buttonDefaultText', + 'buttonSuccessText', + 'buttonProps', + 'onCopySuccess', + 'successStateDuration', + 'triggerCopyOnLoad', + ]) as TextInputProps; + + if (isCopyCommandSupported) { + inputProps.inputRef = ref => { + this.copyInputRef = ref; + }; + } + + const wrapperClasses = classNames(className, { + 'copy-success': copySuccess, + 'text-input-with-copy-button-container': isCopyCommandSupported, + }); + + const copyEvent = isCopyCommandSupported ? { onCopy: this.handleCopyEvent } : {}; + + return ( +
+ + {additionalButtons} + {this.renderCopyButton()} +
+ ); + } +} + +export default TextInputWithCopyButton; diff --git a/src/components/text-input-with-copy-button/__tests__/TextInputWithCopyButton.test.js b/src/components/text-input-with-copy-button/__tests__/TextInputWithCopyButton.test.tsx similarity index 80% rename from src/components/text-input-with-copy-button/__tests__/TextInputWithCopyButton.test.js rename to src/components/text-input-with-copy-button/__tests__/TextInputWithCopyButton.test.tsx index 5df58417c5..1e0370bacf 100644 --- a/src/components/text-input-with-copy-button/__tests__/TextInputWithCopyButton.test.js +++ b/src/components/text-input-with-copy-button/__tests__/TextInputWithCopyButton.test.tsx @@ -2,11 +2,14 @@ import * as React from 'react'; import { shallow, mount } from 'enzyme'; import sinon from 'sinon'; -import TextInputWithCopyButton from '..'; +import TextInputWithCopyButton, { type TextInputWithCopyButtonProps } from '..'; +import Button from '../../button'; const sandbox = sinon.sandbox.create(); +type TextInputWithCopyButtonInstance = InstanceType; +const createInputRef = (select: () => void) => ({ select, scrollLeft: 0 }) as HTMLInputElement; -document.execCommand = () => {}; +document.execCommand = jest.fn(() => true); document.queryCommandSupported = () => false; describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => { @@ -17,8 +20,8 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => const buttonDefaultText = 'copy'; const buttonSuccessText = 'copied'; - const renderComponent = props => - shallow( + const renderComponent = (props: Partial = {}) => + shallow( expect(wrapper.hasClass('text-input-with-copy-button-container')).toBe(true); expect(wrapper.hasClass('copy-success')).toBe(true); - expect(textInputComponent.length).toBe(1); + expect(textInputComponent).toHaveLength(1); expect(textInputComponent.prop('readOnly')).toBe(true); expect(typeof textInputComponent.prop('inputRef')).toBe('function'); expect(textInputComponent.prop('hideOptionalLabel')).toBe(true); @@ -61,7 +64,7 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => const textInputComponent = wrapper.find('TextInput'); expect(wrapper.hasClass('text-input-with-copy-button-container')).toBe(false); - expect(textInputComponent.length).toBe(1); + expect(textInputComponent).toHaveLength(1); expect(textInputComponent.prop('readOnly')).toBe(true); expect(textInputComponent.prop('inputRef')).not.toBeDefined(); }); @@ -72,9 +75,7 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => autofocus: true, }); const instance = wrapper.instance(); - instance.copyInputRef = { - select: selectMock, - }; + instance.copyInputRef = createInputRef(selectMock); instance.componentDidMount(); @@ -89,7 +90,7 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => autofocus: true, }); const instance = wrapper.instance(); - instance.copyInputRef = { select: selectMock }; + instance.copyInputRef = createInputRef(selectMock); instance.componentDidMount(); @@ -97,7 +98,7 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => value: 'http://example.com/', }); - expect(selectMock.mock.calls.length).toBe(1); + expect(selectMock.mock.calls).toHaveLength(1); }); test('should not autofocus if autofocus is enabled, but there is no value', () => { @@ -109,9 +110,7 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => }); const instance = wrapper.instance(); - instance.copyInputRef = { - select: selectMock, - }; + instance.copyInputRef = createInputRef(selectMock); instance.componentDidMount(); @@ -129,7 +128,7 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => value={1} />, ); - const instance = wrapper.instance(); + const instance = wrapper.instance() as TextInputWithCopyButtonInstance; instance.clearCopySuccessTimeout = sandbox.mock(); wrapper.unmount(); @@ -157,13 +156,12 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => const instance = wrapper.instance(); instance.isCopyCommandSupported = true; - const copyButton = wrapper.wrap(instance.renderCopyButton()); - const button = copyButton.find('Button'); + const copyButton = instance.renderCopyButton() as React.ReactElement>; - expect(button.length).toBe(1); - expect(typeof button.prop('onClick')).toBe('function'); - expect(button.prop('type')).toEqual('button'); - expect(button.prop('children')).toEqual(buttonDefaultText); + expect(copyButton.type).toBe(Button); + expect(typeof copyButton.props.onClick).toBe('function'); + expect(copyButton.props.type).toEqual('button'); + expect(copyButton.props.children).toEqual(buttonDefaultText); }); test('should not render a copy button when copy command is not supported', () => { @@ -181,9 +179,8 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => const instance = wrapper.instance(); instance.isCopyCommandSupported = true; - const copyButton = wrapper.wrap(instance.renderCopyButton()); - const button = copyButton.find('Button'); - expect(button.prop('isDisabled')).toBe(true); + const copyButton = instance.renderCopyButton() as React.ReactElement>; + expect(copyButton.props.isDisabled).toBe(true); }); }); @@ -192,9 +189,7 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => const wrapper = renderComponent(); const instance = wrapper.instance(); - instance.copyInputRef = { - select: sandbox.mock(), - }; + instance.copyInputRef = createInputRef(sandbox.mock()); instance.copySelectedText = sandbox.mock(); wrapper.setProps({}); @@ -205,9 +200,7 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => const wrapper = renderComponent(); const instance = wrapper.instance(); - instance.copyInputRef = { - select: sandbox.stub(), - }; + instance.copyInputRef = createInputRef(sandbox.stub()); instance.clearCopySuccessTimeout = sandbox.mock(); instance.handleCopyButtonClick(); @@ -219,9 +212,7 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => const instance = wrapper.instance(); let callback; let data; - instance.copyInputRef = { - select: sandbox.stub(), - }; + instance.copyInputRef = createInputRef(sandbox.stub()); instance.setState = (obj, cb) => { data = obj; callback = cb; @@ -245,9 +236,9 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => const wrapper = renderComponent(); const instance = wrapper.instance(); - sinon.mock(instance, 'animateCopyButton'); + sandbox.mock(instance).expects('animateCopyButton'); - instance.handleCopyEvent(); + instance.handleCopyEvent({} as React.SyntheticEvent); }); test('should call correct callback', () => { @@ -256,7 +247,7 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => }); const instance = wrapper.instance(); - instance.handleCopyEvent(); + instance.handleCopyEvent({} as React.SyntheticEvent); }); }); @@ -280,11 +271,9 @@ describe('components/text-input-with-copy-button/TextInputWithCopyButton', () => }); const instance = wrapper.instance(); - instance.copyInputRef = { - select: sandbox.mock(), - }; + instance.copyInputRef = createInputRef(sandbox.mock()); - instance.handleFocus({}); + instance.handleFocus({} as React.SyntheticEvent); }); }); }); diff --git a/src/components/text-input-with-copy-button/index.js b/src/components/text-input-with-copy-button/index.js.flow similarity index 100% rename from src/components/text-input-with-copy-button/index.js rename to src/components/text-input-with-copy-button/index.js.flow diff --git a/src/components/text-input-with-copy-button/index.ts b/src/components/text-input-with-copy-button/index.ts new file mode 100644 index 0000000000..20293e9257 --- /dev/null +++ b/src/components/text-input-with-copy-button/index.ts @@ -0,0 +1,2 @@ +export { default } from './TextInputWithCopyButton'; +export type { TextInputWithCopyButtonProps } from './TextInputWithCopyButton';