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

Adding addOnBlur to TagInput #1966

Merged
merged 14 commits into from
Mar 8, 2018
16 changes: 14 additions & 2 deletions packages/docs-app/src/examples/labs-examples/tagInputExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const VALUES = [
];

export interface ITagInputExampleState {
addOnBlur?: boolean;
disabled?: boolean;
fill?: boolean;
intent?: boolean;
Expand All @@ -35,6 +36,7 @@ export interface ITagInputExampleState {

export class TagInputExample extends BaseExample<ITagInputExampleState> {
public state: ITagInputExampleState = {
addOnBlur: false,
disabled: false,
fill: false,
intent: false,
Expand All @@ -43,14 +45,15 @@ export class TagInputExample extends BaseExample<ITagInputExampleState> {
values: VALUES,
};

private handleAddOnBlurChange = handleBooleanChange(addOnBlur => this.setState({ addOnBlur }));
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 }));

protected renderExample() {
const { disabled, fill, large, values } = this.state;
const { addOnBlur, disabled, fill, large, values } = this.state;

const classes = classNames({
[Classes.FILL]: fill,
Expand Down Expand Up @@ -84,6 +87,7 @@ export class TagInputExample extends BaseExample<ITagInputExampleState> {
placeholder="Separate values with commas..."
tagProps={getTagProps}
values={values}
addOnBlur={addOnBlur}
/>
);
}
Expand All @@ -104,6 +108,12 @@ export class TagInputExample extends BaseExample<ITagInputExampleState> {
key="disabled"
onChange={this.handleDisabledChange}
/>,
<Switch
checked={this.state.addOnBlur}
label="Add on blur"
key="addOnBlur"
onChange={this.handleAddOnBlurChange}
/>,
],
[
<label key="heading" className={Classes.LABEL}>
Expand All @@ -125,7 +135,9 @@ export class TagInputExample extends BaseExample<ITagInputExampleState> {
];
}

private handleChange = (values: React.ReactNode[]) => this.setState({ values });
private handleChange = (values: React.ReactNode[]) => {
this.setState({ values });
};

private handleClear = () => this.handleChange(this.state.values.length > 0 ? [] : VALUES);
}
2 changes: 1 addition & 1 deletion packages/labs/src/components/tag-input/tag-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

@reactExample TagInputExample

**`TagInput` must be controlled,** meaning the `values` prop is required and event handlers are strongly suggested. Typing in the input and pressing <kbd class="pt-key">enter</kbd> will **add new items** by invoking callbacks. A `separator` prop is supported to allow multiple items to be added at once; the default splits on commas.
**`TagInput` must be controlled,** meaning the `values` prop is required and event handlers are strongly suggested. Typing in the input and pressing <kbd class="pt-key">enter</kbd> will **add new items** by invoking callbacks. If `addOnBlur` is set to true, clicking out of the component will also trigger the callback to add new items. A `separator` prop is supported to allow multiple items to be added at once; the default splits on commas.

**Tags can be removed** by clicking their <span class="pt-icon-standard pt-icon-cross"></span> buttons, or by pressing <kbd class="pt-key">backspace</kbd> repeatedly. Arrow keys can also be used to focus on a particular tag before removing it. The cursor must be at the beginning of the text input for these interactions.

Expand Down
38 changes: 26 additions & 12 deletions packages/labs/src/components/tag-input/tagInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ import {
import * as Classes from "../../common/classes";

export interface ITagInputProps extends IProps {
/**
* If true, onAdd will also be invoked when the input loses focus.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think the word also is necessary here, especially since this is the first prop in the list. I think it reads more clearly without it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

* By default, onAdd is only invoked on enter.
* @default false
*/
addOnBlur?: boolean;

/**
* Whether the component is non-interactive.
* Note that you'll also need to disable the component's `rightElement`,
Expand Down Expand Up @@ -209,6 +216,21 @@ export class TagInput extends AbstractComponent<ITagInputProps, ITagInputState>
);
}

private addTag = (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
if (Utils.isFunction(onChange)) {
shouldClearInput = shouldClearInput || onChange([...values, ...newValues]);
}
// only explicit return false cancels text clearing
if (shouldClearInput !== false) {
this.setState({ inputValue: "" });
}
};

private maybeRenderTag = (tag: React.ReactNode, index: number) => {
if (!tag) {
return null;
Expand Down Expand Up @@ -276,6 +298,9 @@ export class TagInput extends AbstractComponent<ITagInputProps, ITagInputState>
// we only need to "unfocus" 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 (this.state.inputValue.length > 0 && this.props.addOnBlur) {
this.addTag(this.state.inputValue);
}
this.setState({ activeIndex: NONE, isInputFocused: false });
}
});
Expand All @@ -298,18 +323,7 @@ export class TagInput extends AbstractComponent<ITagInputProps, ITagInputState>
let activeIndexToEmit = activeIndex;

if (event.which === Keys.ENTER && value.length > 0) {
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
if (Utils.isFunction(onChange)) {
shouldClearInput = shouldClearInput || onChange([...values, ...newValues]);
}
// only explicit return false cancels text clearing
if (shouldClearInput !== false) {
this.setState({ inputValue: "" });
}
this.addTag(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 Down
29 changes: 27 additions & 2 deletions packages/labs/test/tagInputTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,31 @@ describe("<TagInput>", () => {
assert.deepEqual(onAdd.args[0][0], [NEW_VALUE]);
});

it("is invoked on blur when addOnBlur is true", done => {
const onAdd = sinon.stub();
const wrapper = mount(<TagInput values={VALUES} addOnBlur={true} onAdd={onAdd} />);
// simulate typing input text
wrapper.setProps({ inputProps: { value: NEW_VALUE } });
wrapper.find("input").simulate("change", { currentTarget: { value: NEW_VALUE } });
const fakeEvent = { flag: "yes" };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this flag property for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, yeah I can just call simulate("blur")

wrapper.simulate("blur", fakeEvent);
setTimeout(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Add a comment explaining why setTimeout is necessary. Is this to wait for the focus to change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added comment

assert.isTrue(onAdd.calledOnce);
done();
});
});

it("is not invoked on blur when addOnBlur is false", done => {
const onAdd = sinon.stub();
const wrapper = mount(<TagInput values={VALUES} inputProps={{ value: NEW_VALUE }} onAdd={onAdd} />);
const fakeEvent = { flag: "yes" };
wrapper.simulate("blur", fakeEvent);
setTimeout(() => {
assert.isTrue(onAdd.notCalled);
done();
});
});

it("does not clear the input if onAdd returns false", () => {
const onAdd = sinon.stub().returns(false);
const wrapper = mountTagInput(onAdd);
Expand Down Expand Up @@ -383,8 +408,8 @@ describe("<TagInput>", () => {
function createInputKeydownEventMetadata(value: string, which: number) {
return {
currentTarget: { value },
// Enzyme throws errors if we don't mock the preventDefault method.
preventDefault: () => {
// Enzyme throws errors if we don't mock the stopPropagation method.
stopPropagation: () => {
return;
},
which,
Expand Down