Skip to content

Commit

Permalink
[Labs] new TagInput component! (#1232)
Browse files Browse the repository at this point in the history
* initial TagInput implementation
* docs + polish
* tests! sufficient coverage 👍
* move code to subdirectory, use "fake" names
* add Tag active prop and .pt-active style support
* left/right arrows to navigate through tags list and remove _any_ (not just last)
* fix lint
* refactor keyboard logic and introduce NONE constant
* set explicit width to break long words
* notes, remove commented code
* 🌳  remove logs
* rename dir to tag-input
* add docs about keyboard interactions
  • Loading branch information
giladgray authored and llorca committed Jun 16, 2017
1 parent 49b91ac commit abb4f5d
Show file tree
Hide file tree
Showing 15 changed files with 565 additions and 16 deletions.
4 changes: 2 additions & 2 deletions packages/core/src/common/_mixins.scss
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ $pt-dark-intent-text-colors: (
word-wrap: normal;
}

@mixin focus-outline() {
@mixin focus-outline($offset: 2px) {
outline: rgba($blue3, 0.5) auto 2px;
outline-offset: 2px;
outline-offset: $offset;
-moz-outline-radius: 6px;
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/common/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* and https://github.com/palantir/blueprint/blob/master/PATENTS
*/

export const BACKSPACE = 8;
export const TAB = 9;
export const ENTER = 13;
export const SHIFT = 16;
Expand Down
14 changes: 7 additions & 7 deletions packages/core/src/components/button/abstractButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ import { safeInvoke } from "../../common/utils";
import { Spinner } from "../spinner/spinner";

export interface IButtonProps extends IActionProps {
/**
* If set to `true`, the button will display in an active state.
* This is equivalent to setting `className="pt-active"`.
* @default false
*/
active?: boolean;

/** A ref handler that receives the native HTML element backing this component. */
elementRef?: (ref: HTMLElement) => any;

Expand All @@ -28,13 +35,6 @@ export interface IButtonProps extends IActionProps {
*/
loading?: boolean;

/**
* If set to `true`, the button will display in an active state.
* This is equivalent to setting `pt-active` via className.
* @default false
*/
active?: boolean;

/**
* HTML `type` attribute of button. Common values are `"button"` and `"submit"`.
* Note that this prop has no effect on `AnchorButton`; it only affects `Button`.
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/components/tag/_common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ $tag-padding-large: ($tag-height-large - $pt-icon-size-large) / 2 !default;
&.pt-tag-removable {
padding-right: $tag-height;
}

&.pt-active {
@include focus-outline(0);
}
}

@mixin pt-tag-large() {
Expand Down
19 changes: 14 additions & 5 deletions packages/core/src/components/tag/tag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,34 @@ import { isFunction } from "../../common/utils";

import * as Classes from "../../common/classes";

export interface ITagProps extends IProps, IIntentProps, React.HTMLProps<HTMLSpanElement> {
export interface ITagProps extends IProps, IIntentProps, React.HTMLAttributes<Tag> {
/**
* If set to `true`, the tag will display in an active state.
* This is equivalent to setting `className="pt-active"`.
* @default false
*/
active?: boolean;

/**
* Click handler for remove button.
* Button will only be rendered if this prop is defined.
*/
onRemove?: (e: React.MouseEvent<HTMLSpanElement>) => void;
onRemove?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}

@PureRender
export class Tag extends React.Component<ITagProps, {}> {
public static displayName = "Blueprint.Tag";

public render() {
const { className, intent, onRemove } = this.props;
const { active, className, intent, onRemove } = this.props;
const tagClasses = classNames(Classes.TAG, Classes.intentClass(intent), {
[Classes.TAG_REMOVABLE]: onRemove != null,
[Classes.ACTIVE]: active,
}, className);
const button =
isFunction(onRemove) ? <button type="button" className={Classes.TAG_REMOVE} onClick={onRemove} /> : undefined;
const button = isFunction(onRemove)
? <button type="button" className={Classes.TAG_REMOVE} onClick={onRemove} />
: undefined;

return (
<span {...removeNonHTMLProps(this.props)} className={tagClasses}>
Expand Down
1 change: 1 addition & 0 deletions packages/labs/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
*/

export * from "./selectExample";
export * from "./tagInputExample";
2 changes: 1 addition & 1 deletion packages/labs/examples/selectExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export class SelectExample extends BaseExample<ISelectExampleState> {
/>,
<Switch
key="minimal"
label="Minimal style"
label="Minimal popover style"
checked={this.state.minimal}
onChange={this.handleMinimalChange}
/>,
Expand Down
105 changes: 105 additions & 0 deletions packages/labs/examples/tagInputExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright 2017 Palantir Technologies, Inc. All rights reserved.
* Licensed under the BSD-3 License as modified (the “License”); you may obtain a copy
* of the license at https://github.com/palantir/blueprint/blob/master/LICENSE
* and https://github.com/palantir/blueprint/blob/master/PATENTS
*/

import * as classNames from "classnames";
import * as React from "react";

import { Classes, Intent, ITagProps, Switch } from "@blueprintjs/core";
import { BaseExample, handleBooleanChange } from "@blueprintjs/docs";
import { TagInput } from "../src/tag-input/tagInput";

const INTENTS = [Intent.NONE, Intent.PRIMARY, Intent.SUCCESS, Intent.DANGER, Intent.WARNING];

export interface ITagInputExampleState {
fill?: boolean;
intent?: boolean;
large?: boolean;
minimal?: boolean;
values?: string[];
}

export class TagInputExample extends BaseExample<ITagInputExampleState> {
public state: ITagInputExampleState = {
fill: false,
intent: false,
large: false,
minimal: false,
values: ["Albert", "Bartholomew", "Casper"],
};

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 { fill, large, values } = this.state;

const classes = classNames({
[Classes.FILL]: fill,
[Classes.LARGE]: large,
});

// define a new function every time so switch changes will cause it to re-render
// NOTE: avoid this pattern in your app (use this.getTagProps instead); this is only for
// example purposes!!
const getTagProps = (_v: string, index: number): ITagProps => ({
className: this.state.minimal ? Classes.MINIMAL : "",
intent: this.state.intent ? INTENTS[index % INTENTS.length] : Intent.NONE,
});

return (
<TagInput
className={classes}
onAdd={this.handleAdd}
onRemove={this.handleRemove}
tagProps={getTagProps}
values={values}
/>
);
}

protected renderOptions() {
return [
[
<Switch
checked={this.state.fill}
label="Fill container width"
key="fill"
onChange={this.handleFillChange}
/>,
<Switch
checked={this.state.large}
label="Large"
key="large"
onChange={this.handleLargeChange}
/>,
], [
<label key="heading" className={Classes.LABEL}>Tag props</label>,
<Switch
checked={this.state.minimal}
label="Use minimal tags"
key="minimal"
onChange={this.handleMinimalChange}
/>,
<Switch
checked={this.state.intent}
label="Cycle through intents"
key="intent"
onChange={this.handleIntentChange}
/>,
],
];
}

private handleAdd = (newValue: string) => {
this.setState({ values: [...this.state.values, newValue] });
}
private handleRemove = (_removedValue: string, removedIndex: number) => {
this.setState({ values: this.state.values.filter((_, i) => i !== removedIndex) });
}
}
3 changes: 2 additions & 1 deletion packages/labs/src/blueprint-labs.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
* and https://github.com/palantir/blueprint/blob/master/PATENTS
*/

@import "select/select";
@import "./select/select";
@import "./tag-input/tag-input";
1 change: 1 addition & 0 deletions packages/labs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@

export * from "./query-list/queryList";
export * from "./select/select";
export * from "./tag-input/tagInput";
22 changes: 22 additions & 0 deletions packages/labs/src/labs.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,25 @@ An object with the following properties will be passed to an `QueryList` `render
This interface is generic, accepting a type parameter `<T>` for an item in the list.

@interface IQueryListRendererProps

@## TagInput

`TagInput` renders [`Tag`](#core/components/tag)s inside an input, followed by an actual text input. The container is merely styled to look like a Blueprint input; the actual editable element appears after the last tag. Clicking anywhere on the container will focus the text input for seamless interaction.


@reactExample TagInputExample

`TagInput` must be controlled, meaning the `values` prop is required and event handlers are strongly suggested. The component controls the text input internally to support the `onAdd` event, but you can wrest control by supplying your own `inputProps`.

`Tag` appearance can be customized with `tagProps`: supply an object to apply the same props to every tag, or supply a callback to apply dynamic props per tag. Tag `values` must be an array of strings so you may need a transformation step between your state and these props.

Tags can be removed by clicking their X 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.

<div class="pt-callout pt-intent-primary pt-icon-info-sign">
<h5>Handling long words</h5>
Set an explicit `width` on `.pt-tag-input` to cause long words to wrap onto multiple lines. Either supply a specific pixel value, or use `<TagInput className="pt-fill">` to fill its container's width (try this in the example above).
</div>

@interface ITagInputProps
64 changes: 64 additions & 0 deletions packages/labs/src/tag-input/tag-input.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2017 Palantir Technologies, Inc. All rights reserved.
* Licensed under the BSD-3 License as modified (the “License”); you may obtain a copy
* of the license at https://github.com/palantir/blueprint/blob/master/LICENSE
* and https://github.com/palantir/blueprint/blob/master/PATENTS
*/

@import "~@blueprintjs/core/src/common/variables";
@import "~@blueprintjs/core/src/components/forms/common";
@import "~@blueprintjs/core/src/components/tag/common";

$input-padding: ($pt-input-height - $tag-height) / 2;

.pt-tag-input {
display: flex;
flex-wrap: wrap;
cursor: text;
height: auto;
padding: $input-padding 0 0 $input-padding;

.pt-tag {
margin: 0 $input-padding $input-padding 0;
// NOTE: in order to wrap long words, you must set explicit width on TagInput,
// or use .pt-fill CSS class modifier.
overflow-wrap: break-word;
}

.pt-input-ghost {
// input fills remaining line
flex: 1 1 auto;
margin-bottom: $input-padding;
// essentially a min-width, cuz flex allows it to grow or shrink:
width: $pt-grid-size * 10;
line-height: $tag-height;
}

&.pt-large {
height: auto;

.pt-input-ghost {
line-height: $tag-height-large;
}
}

&.pt-active {
box-shadow: input-transition-shadow($input-shadow-color-focus, true), $input-box-shadow-focus;
background-color: $input-background-color;
}
}

// TODO: this is probably a useful modifier that we should pull into core, and use in EditableText
.pt-input-ghost {
// reset browser input styles (we're using an input solely because you can type in it)
border: none;
box-shadow: none;
background: none;
padding: 0;

&:focus {
// remove focus state too
// stylelint-disable-next-line declaration-no-important
outline: none !important;
}
}
Loading

1 comment on commit abb4f5d

@blueprint-bot
Copy link

Choose a reason for hiding this comment

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

[Labs] new TagInput component! (#1232)

Preview: documentation
Coverage: core | datetime

Please sign in to comment.