Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TagInput] paste support + addOnPaste prop #2592

Merged
merged 3 commits into from
Jun 12, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions packages/core/src/components/tag-input/tagInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export interface ITagInputProps extends IProps {
*/
addOnBlur?: boolean;

/**
* If true, `onAdd` will be invoked when the user pastes text into the
* input. Otherwise, pasted text will remain in the input.
* @default true
*/
addOnPaste?: boolean;

/**
* Whether the component is non-interactive.
* Note that you'll also need to disable the component's `rightElement`,
Expand Down Expand Up @@ -128,9 +135,9 @@ export interface ITagInputProps extends IProps {
rightElement?: JSX.Element;

/**
* Separator pattern used to split input text into multiple values.
* Separator pattern used to split input text into multiple values. Default value splits on commas and newlines.
* Explicit `false` value disables splitting (note that `onAdd` will still receive an array of length 1).
* @default ","
* @default /[,\n\r]/
*/
separator?: string | RegExp | false;

Expand Down Expand Up @@ -168,9 +175,11 @@ export class TagInput extends AbstractPureComponent<ITagInputProps, ITagInputSta
public static displayName = "Blueprint2.TagInput";

public static defaultProps: Partial<ITagInputProps> & object = {
addOnBlur: false,
addOnPaste: true,
inputProps: {},
inputValue: "",
separator: ",",
separator: /[,\n\r]/,
tagProps: {},
};

Expand Down Expand Up @@ -233,6 +242,7 @@ export class TagInput extends AbstractPureComponent<ITagInputProps, ITagInputSta
onChange={this.handleInputChange}
onKeyDown={this.handleInputKeyDown}
onKeyUp={this.handleInputKeyUp}
onPaste={this.handleInputPaste}
placeholder={resolvedPlaceholder}
ref={this.refHandlers.input}
className={classNames(Classes.INPUT_GHOST, inputProps.className)}
Expand All @@ -244,9 +254,8 @@ export class TagInput extends AbstractPureComponent<ITagInputProps, ITagInputSta
);
}

private addTag = (value: string) => {
private addTags = (value: string) => {
const { onAdd, onChange, values } = this.props;
// enter key on non-empty string invokes onAdd
const newValues = this.getValues(value);
let shouldClearInput = Utils.safeInvoke(onAdd, newValues);
// avoid a potentially expensive computation if this prop is omitted
Expand Down Expand Up @@ -321,14 +330,13 @@ export class TagInput extends AbstractPureComponent<ITagInputProps, ITagInputSta
}
};

private handleContainerBlur = () => {
private handleContainerBlur = ({ currentTarget }: React.FocusEvent<HTMLDivElement>) => {
requestAnimationFrame(() => {
// this event is attached to the container element to capture all blur events from inside.
// we only need to "unfocus" if the blur event is leaving the container.
// we only care if the blur event is leaving the container.
// defer this check using rAF so activeElement will have updated.
if (this.inputElement != null && !this.inputElement.parentElement.contains(document.activeElement)) {
if (!currentTarget.contains(document.activeElement)) {
if (this.props.addOnBlur && this.state.inputValue !== undefined && this.state.inputValue.length > 0) {
this.addTag(this.state.inputValue);
this.addTags(this.state.inputValue);
}
this.setState({ activeIndex: NONE, isInputFocused: false });
}
Expand All @@ -353,7 +361,7 @@ export class TagInput extends AbstractPureComponent<ITagInputProps, ITagInputSta
let activeIndexToEmit = activeIndex;

if (event.which === Keys.ENTER && value.length > 0) {
this.addTag(value);
this.addTags(value);
} else if (selectionEnd === 0 && this.props.values.length > 0) {
// cursor at beginning of input allows interaction with tags.
// use selectionEnd to verify cursor position and no text selection.
Expand All @@ -376,6 +384,14 @@ export class TagInput extends AbstractPureComponent<ITagInputProps, ITagInputSta
this.invokeKeyPressCallback("onKeyUp", event, this.state.activeIndex);
};

private handleInputPaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
const value = event.clipboardData.getData("text");
if (this.props.addOnPaste && value.length > 0) {
event.preventDefault();
this.addTags(value);
}
};

private handleRemoveTag = (event: React.MouseEvent<HTMLSpanElement>) => {
// using data attribute to simplify callback logic -- one handler for all children
const index = +event.currentTarget.parentElement.getAttribute("data-tag-index");
Expand Down
17 changes: 17 additions & 0 deletions packages/core/test/tag-input/tagInputTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,23 @@ describe("<TagInput>", () => {
});
});

it("is invoked on paste when addOnPaste=true", () => {
const text = "pasted\ntext";
const onAdd = sinon.stub();
const wrapper = mount(<TagInput values={VALUES} addOnPaste={true} onAdd={onAdd} />);
wrapper.find("input").simulate("paste", { clipboardData: { getData: () => text } });
assert.isTrue(onAdd.calledOnce);
assert.deepEqual(onAdd.args[0][0], text.split("\n"));
});

it("is not invoked on paste when addOnPaste=false", () => {
const text = "pasted\ntext";
const onAdd = sinon.stub();
const wrapper = mount(<TagInput values={VALUES} addOnPaste={false} onAdd={onAdd} />);
wrapper.find("input").simulate("paste", { clipboardData: { getData: () => text } });
assert.isTrue(onAdd.notCalled);
});

it("does not clear the input if onAdd returns false", () => {
const onAdd = sinon.stub().returns(false);
const wrapper = mountTagInput(onAdd);
Expand Down
19 changes: 10 additions & 9 deletions packages/docs-app/src/examples/core-examples/tagInputExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const VALUES = [

export interface ITagInputExampleState {
addOnBlur: boolean;
addOnPaste: boolean;
disabled: boolean;
fill: boolean;
intent: boolean;
Expand All @@ -35,6 +36,7 @@ export interface ITagInputExampleState {
export class TagInputExample extends React.PureComponent<IExampleProps, ITagInputExampleState> {
public state: ITagInputExampleState = {
addOnBlur: false,
addOnPaste: true,
disabled: false,
fill: false,
intent: false,
Expand All @@ -44,18 +46,19 @@ export class TagInputExample extends React.PureComponent<IExampleProps, ITagInpu
};

private handleAddOnBlurChange = handleBooleanChange(addOnBlur => this.setState({ addOnBlur }));
private handleAddOnPasteChange = handleBooleanChange(addOnPaste => this.setState({ addOnPaste }));
private handleDisabledChange = handleBooleanChange(disabled => this.setState({ disabled }));
private handleFillChange = handleBooleanChange(fill => this.setState({ fill }));
private handleIntentChange = handleBooleanChange(intent => this.setState({ intent }));
private handleLargeChange = handleBooleanChange(large => this.setState({ large }));
private handleMinimalChange = handleBooleanChange(minimal => this.setState({ minimal }));

public render() {
const { addOnBlur, disabled, fill, large, values } = this.state;
const { minimal, values, ...props } = this.state;

const clearButton = (
<Button
disabled={disabled}
disabled={props.disabled}
icon={values.length > 1 ? "cross" : "refresh"}
minimal={true}
onClick={this.handleClear}
Expand All @@ -67,17 +70,14 @@ export class TagInputExample extends React.PureComponent<IExampleProps, ITagInpu
// example purposes!!
const getTagProps = (_v: string, index: number): ITagProps => ({
intent: this.state.intent ? INTENTS[index % INTENTS.length] : Intent.NONE,
large,
minimal: this.state.minimal,
large: props.large,
minimal,
});

return (
<Example options={this.renderOptions()} {...this.props}>
<TagInput
addOnBlur={addOnBlur}
disabled={disabled}
fill={fill}
large={large}
{...props}
leftIcon="user"
onChange={this.handleChange}
placeholder="Separate values with commas..."
Expand All @@ -93,10 +93,11 @@ export class TagInputExample extends React.PureComponent<IExampleProps, ITagInpu
return (
<>
<H5>Props</H5>
<Switch label="Fill container width" checked={this.state.fill} onChange={this.handleFillChange} />
<Switch label="Large" checked={this.state.large} onChange={this.handleLargeChange} />
<Switch label="Disabled" checked={this.state.disabled} onChange={this.handleDisabledChange} />
<Switch label="Add on blur" checked={this.state.addOnBlur} onChange={this.handleAddOnBlurChange} />
<Switch label="Add on paste" checked={this.state.addOnPaste} onChange={this.handleAddOnPasteChange} />
<Switch label="Fill container width" checked={this.state.fill} onChange={this.handleFillChange} />
<H5>Tag props</H5>
<Switch label="Use minimal tags" checked={this.state.minimal} onChange={this.handleMinimalChange} />
<Switch label="Cycle through intents" checked={this.state.intent} onChange={this.handleIntentChange} />
Expand Down