refactor: enhance tag input UI (#44)

* refactor: enhance tag input UI

* style: fix tslint errors

* Update tagInputSize.scss

* Update tagInputSize.scss
This commit is contained in:
kunzheng 2020-02-15 18:52:31 -08:00 коммит произвёл GitHub
Родитель d1f6f5a186
Коммит 5c9fa5de3b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 450 добавлений и 620 удалений

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

Просмотреть файл

@ -125,6 +125,32 @@ const blueButtonPalette = {
white: "#272b30",
};
const darkThemePalette = {
neutralLighterAlt: "#282828",
neutralLighter: "#313131",
neutralLight: "#3f3f3f",
neutralQuaternaryAlt: "#484848",
neutralQuaternary: "#4f4f4f",
neutralTertiaryAlt: "#6d6d6d",
neutralTertiary: "#c8c8c8",
neutralSecondary: "#d0d0d0",
neutralPrimaryAlt: "#dadada",
neutralPrimary: "#ffffff",
neutralDark: "#f4f4f4",
black: "#f8f8f8",
white: "#1f1f1f",
themePrimary: "#ffffff",
themeLighterAlt: "#020609",
themeLighter: "#091823",
themeLight: "#112d43",
themeTertiary: "#235a85",
themeSecondary: "#3385c3",
themeDarkAlt: "#4ba0e1",
themeDark: "#65aee6",
themeDarker: "#8ac2ec",
accent: "#3a96dd",
};
export function getPrimaryWhiteTheme() {
return createTheme({palette: whiteButtonPalette});
}
@ -144,3 +170,7 @@ export function getPrimaryGreyTheme() {
export function getPrimaryBlueTheme() {
return createTheme({palette: blueButtonPalette});
}
export function getDarkTheme() {
return createTheme({palette: darkThemePalette});
}

Просмотреть файл

@ -1,217 +0,0 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import React from "react";
import { FontIcon } from "office-ui-fabric-react";
import { Align } from "../align/align";
import { ITag, FieldType, FieldFormat } from "../../../../models/applicationState";
import { strings } from "../../../../common/strings";
import "./tagContextMenu.scss";
/**
* Properties for TagContextMenu
* @member tag - ITag
*/
export interface ITagContextMenuProps {
tag: ITag;
onChange?: (oldTag: ITag, newTag: ITag) => void;
}
/**
* State for TagContextMenu
* @member tag - ITag
*/
export interface ITagContextMenuState {
tag: ITag;
showFormat?: boolean;
showType?: boolean;
}
/**
* Generic modal that displays a message
*/
export default class TagContextMenu extends React.Component<ITagContextMenuProps, ITagContextMenuState> {
private static filterFormat(type: FieldType): FieldFormat[] {
switch (type) {
case FieldType.String:
return [
FieldFormat.NotSpecified,
FieldFormat.Alphanumberic,
FieldFormat.NoWhiteSpaces,
];
case FieldType.Number:
return [
FieldFormat.NotSpecified,
FieldFormat.Currency,
];
case FieldType.Integer:
return [
FieldFormat.NotSpecified,
];
case FieldType.Date:
return [
FieldFormat.NotSpecified,
FieldFormat.DMY,
FieldFormat.MDY,
FieldFormat.YMD,
];
case FieldType.Time:
return [
FieldFormat.NotSpecified,
];
default:
return [ FieldFormat.NotSpecified ];
}
}
public state: ITagContextMenuState = {
tag: this.props.tag,
showFormat: false,
showType: false,
};
private typeRef = React.createRef<HTMLDivElement>();
private formatRef = React.createRef<HTMLDivElement>();
public render() {
const tag = this.state.tag;
const types = Object.keys(FieldType);
const formats = TagContextMenu.filterFormat(tag.type);
const align = {
// Align top right of source node (dropdown) with top left of target node (tag name row)
points: ["tr", "br"],
// Offset source node by 0px in x and 3px in y
offset: [0, 3],
// Auto adjust position when source node is overflowed
overflow: {adjustX: true, adjustY: true},
};
return (
<div className = "field-background field-background-color">
<div className = "tag-field justify-content-start">
<div className = "row-4 tag-field-item">
<div
ref={this.typeRef}
className="field-background-container"
onClick={this.handleTypeShow}>
<FontIcon iconName="Link" />
<span className="type-selected">{tag.type ? tag.type : strings.tags.toolbar.type}</span>
<FontIcon iconName="ChevronDown" className="pr-1" />
</div>
<Align align={align} target={() => this.typeRef.current} monitorWindowResize={true}>
{
this.state.showType &&
<div className={["tag-input-portal", "format-list", "format-items-list"].join(" ")}>
{
types.filter((type) => {
return FieldType[type] !== tag.type;
}).map((type) => {
return (
this.getTypeListItem(this, type)
);
})
}
</div>
}
</Align>
</div>
<div className = "horizontal-line"></div>
<div className = "row-4 tag-field-item">
<div
ref={this.formatRef}
className = "field-background-container"
onClick={this.handleFormatShow}>
<FontIcon iconName="Link" />
<span>{tag.format ? tag.format : strings.tags.toolbar.format}</span>
<FontIcon iconName="ChevronDown" className="pr-1" />
</div>
<Align align={align} target={() => this.formatRef.current}>
{
this.state.showFormat &&
<div className = {["tag-input-portal", "format-list", "format-items-list"].join(" ")}>
{
formats.filter((format) => {
return format !== tag.format;
}).map((format) => {
return (
this.getFormatListItem(this, format)
);
})
}
</div>
}
</Align>
</div>
</div>
</div>
);
}
private handleTypeChange = (event) => {
const oldTag = this.state.tag;
const newTag: ITag = {
...oldTag,
type: event.target.value as FieldType,
format: FieldFormat.NotSpecified,
};
this.setState({ tag: newTag, showType: false }, () => {
if (this.props.onChange) {
this.props.onChange(oldTag, newTag);
}
});
}
private handleTypeShow = (e) => {
if (e.type === "click") {
this.setState({showType: !this.state.showType, showFormat: false});
}
}
private handleFormatShow = (e) => {
if (e.type === "click") {
this.setState({showFormat: !this.state.showFormat, showType: false});
}
}
private handleFormatChange = (event) => {
const oldTag = this.state.tag;
const newTag: ITag = {
...oldTag,
format: event.target.value,
};
this.setState({ tag: newTag, showFormat: false }, () => {
if (this.props.onChange) {
this.props.onChange(oldTag, newTag);
}
});
}
private getTypeListItem(props, type) {
return (
<button type="button"
key={type}
onClick={props.handleTypeChange}
value={FieldType[type]}
className="list-items list-items-color"
>
{FieldType[type]}
</button>
);
}
private getFormatListItem(props, format) {
return(
<button type="button"
key={format}
onClick={props.handleFormatChange}
value={format}
className="list-items list-items-color"
>
{format}
</button>
);
}
}

Просмотреть файл

@ -1,72 +0,0 @@
@import "tagInput.scss";
.field-background {
width: $tagInputWidth - $tagColorWidth;
padding: 0px 0px 5px 0px;
&-color {
background-color: #32363B;
}
&-container {
display: flex;
justify-content: space-between;
color: white;
padding: 0px 0px 0px 20px;
}
}
.horizontal-line {
height: 0.1;
width: 100%;
border: 0.5px solid white;
margin: 5px 0px;
}
.tag-field {
width: 100%;
height: 100%;
margin: 0px;
}
.tag-field-item {
width: 100%;
padding: 4px 0px 4px 0px;
}
.format-list {
width: $tagInputWidth - $tagColorWidth;
border: white 1px;
}
.format-items-hide {
display:none;
}
.format-items-list {
width: $tagInputWidth - $tagColorWidth;
list-style-type: none;
padding: 0px;
margin: 0px;
display: inline-flex;
flex-direction: column;
justify-items: self-start;
}
.list-items {
border: 0px;
width:100%;
display: inline-flex;
justify-items: flex-start;
padding: 0px 0px 0px 20px;
&-color {
background-color: #32363B;
color: white;
}
&-color:hover {
background-color: #2D2F31;
color: white;
}
}

Просмотреть файл

@ -176,13 +176,13 @@
cursor: pointer;
}
&-color-edit:hover {
&-color {
width: $tagColorWidth;
&-edit:hover {
background: $darker-1;
cursor: pointer;
}
&-color {
width: $tagColorWidth;
}
&-lock-icon {
@ -245,6 +245,10 @@
}
}
}
&-contextual-menu {
width: $tagContextualMenuWidth;
}
}
&-index-span {

Просмотреть файл

@ -3,7 +3,7 @@
import React from "react";
import { ReactWrapper, mount } from "enzyme";
import { TagInput, ITagInputProps, ITagInputState } from "./tagInput";
import { TagInput, TagOperationMode, ITagInputProps, ITagInputState } from "./tagInput";
import MockFactory from "../../../../common/mockFactory";
import { ITag } from "../../../../models/applicationState";
import TagInputItem, { ITagInputItemProps } from "./tagInputItem";
@ -43,7 +43,7 @@ describe("Tag Input Component", () => {
const wrapper = createComponent(props);
wrapper.find(".tag-color").first().simulate("click");
expect(props.onTagClick).toBeCalledWith(props.tags[0]);
expect(wrapper.state().clickedColor).toBe(true);
expect(wrapper.state().tagOperation === TagOperationMode.ColorPicker).toBe(true);
expect(props.onCtrlTagClick).not.toBeCalled();
});
@ -51,19 +51,18 @@ describe("Tag Input Component", () => {
const props = createProps();
const wrapper = createComponent(props);
wrapper.find("div.tag-name-container").first().simulate("click", { altKey: true } );
expect(wrapper.state().editingTag).toEqual(props.tags[0]);
expect(wrapper.state().selectedTag).toEqual(props.tags[0]);
expect(wrapper.exists("input.tag-name-editor")).toBe(true);
});
it("Edits tag color when alt clicked", () => {
const props = createProps();
const wrapper = createComponent(props);
expect(wrapper.state().clickedColor).toBe(false);
expect(wrapper.state().tagOperation === TagOperationMode.None).toBe(false);
expect(wrapper.exists("div.color-picker-container")).toBe(false);
wrapper.find("div.tag-color").first().simulate("click", { altKey: true } );
expect(wrapper.state().clickedColor).toBe(true);
expect(wrapper.state().showColorPicker).toBe(true);
expect(wrapper.state().editingTag).toEqual(props.tags[0]);
expect(wrapper.state().tagOperation === TagOperationMode.ColorPicker).toBe(true);
expect(wrapper.state().selectedTag).toEqual(props.tags[0]);
expect(wrapper.exists("div.color-picker-container")).toBe(true);
// Get color picker and call onEditColor function
const picker = wrapper.find(ColorPicker).instance() as ColorPicker;
@ -85,7 +84,7 @@ describe("Tag Input Component", () => {
const wrapper = createComponent(props);
wrapper.find(".tag-color").first().simulate("click", { ctrlKey: true });
expect(props.onCtrlTagClick).toBeCalledWith(props.tags[0]);
expect(wrapper.state().clickedColor).toBe(true);
expect(wrapper.state().tagOperation === TagOperationMode.ColorPicker).toBe(true);
expect(props.onTagClick).not.toBeCalled();
});
@ -167,7 +166,7 @@ describe("Tag Input Component", () => {
const wrapper = createComponent(props);
wrapper.find("div.tag-name-container").first().simulate("click");
wrapper.find("div.tag-input-toolbar-item.edit").simulate("click");
expect(wrapper.state().editingTag).toEqual(tags[0]);
expect(wrapper.state().selectedTag).toEqual(tags[0]);
expect(wrapper.exists("input.tag-name-editor")).toBe(true);
});
@ -175,13 +174,12 @@ describe("Tag Input Component", () => {
const tags = MockFactory.createTestTags();
const props = createProps(tags);
const wrapper = createComponent(props);
expect(wrapper.state().clickedColor).toBe(false);
expect(wrapper.state().tagOperation === TagOperationMode.None).toBe(false);
expect(wrapper.exists("div.color-picker-container")).toBe(false);
wrapper.find("div.tag-color").first().simulate("click");
expect(wrapper.state().clickedColor).toBe(true);
expect(wrapper.state().tagOperation === TagOperationMode.ColorPicker).toBe(true);
wrapper.find("div.tag-input-toolbar-item.edit").simulate("click");
expect(wrapper.state().showColorPicker).toBe(true);
expect(wrapper.state().editingTag).toEqual(tags[0]);
expect(wrapper.state().selectedTag).toEqual(tags[0]);
expect(wrapper.exists("div.color-picker-container")).toBe(true);
// Get color picker and call onEditColor function
const picker = wrapper.find(ColorPicker).instance() as ColorPicker;

Просмотреть файл

@ -3,8 +3,15 @@
import React, { KeyboardEvent, RefObject } from "react";
import ReactDOM from "react-dom";
import { FontIcon } from "office-ui-fabric-react";
import { Align } from "../align/align";
import {
ContextualMenu,
Customizer,
FontIcon,
IContextualMenuItem,
ICustomizations,
} from "office-ui-fabric-react";
import { strings } from "../../../../common/strings";
import { getDarkTheme } from "../../../../common/themes";
import { AlignPortal } from "../align/alignPortal";
import { randomIntInRange } from "../../../../common/utils";
import { IRegion, ITag, ILabel, FieldType, FieldFormat } from "../../../../models/applicationState";
@ -14,11 +21,16 @@ import "../condensedList/condensedList.scss";
import TagInputItem, { ITagInputItemProps, ITagClickProps } from "./tagInputItem";
import TagInputToolbar from "./tagInputToolbar";
import { toast } from "react-toastify";
import { strings } from "../../../../common/strings";
import TagContextMenu from "./tagContentMenu";
// tslint:disable-next-line:no-var-requires
const tagColors = require("../../common/tagColors.json");
export enum TagOperationMode {
None,
ColorPicker,
ContextualMenu,
Rename,
}
export interface ITagInputProps {
/** Current list of tags */
tags: ITag[];
@ -56,48 +68,102 @@ export interface ITagInputProps {
export interface ITagInputState {
tags: ITag[];
clickedColor: boolean;
clickedDropDown: boolean;
showColorPicker: boolean;
showDropDown: boolean;
tagOperation: TagOperationMode;
addTags: boolean;
searchTags: boolean;
searchQuery: string;
selectedTag: ITag;
editingTag: ITag;
editingTagNode: Element;
}
function defaultDOMNode(): Element {
return document.createElement("div");
}
function filterFormat(type: FieldType): FieldFormat[] {
switch (type) {
case FieldType.String:
return [
FieldFormat.NotSpecified,
FieldFormat.Alphanumberic,
FieldFormat.NoWhiteSpaces,
];
case FieldType.Number:
return [
FieldFormat.NotSpecified,
FieldFormat.Currency,
];
case FieldType.Integer:
return [
FieldFormat.NotSpecified,
];
case FieldType.Date:
return [
FieldFormat.NotSpecified,
FieldFormat.DMY,
FieldFormat.MDY,
FieldFormat.YMD,
];
case FieldType.Time:
return [
FieldFormat.NotSpecified,
];
default:
return [ FieldFormat.NotSpecified ];
}
}
export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
public state: ITagInputState = {
tags: this.props.tags || [],
clickedColor: false,
clickedDropDown: false,
showColorPicker: false,
showDropDown: false,
tagOperation: TagOperationMode.None,
addTags: this.props.showTagInputBox,
searchTags: this.props.showSearchBox,
searchQuery: "",
selectedTag: null,
editingTag: null,
editingTagNode: null,
};
private tagItemRefs: Map<string, TagInputItem> = new Map<string, TagInputItem>();
private inputRef: RefObject<HTMLInputElement>;
private colorPickerNode = defaultDOMNode();
constructor(props) {
super(props);
this.inputRef = React.createRef();
}
public componentDidUpdate(prevProps: ITagInputProps) {
if (prevProps.tags !== this.props.tags) {
let selectedTag = this.state.selectedTag;
if (selectedTag) {
selectedTag = this.props.tags.find((tag) => this.isNameEqual(tag, selectedTag));
}
this.setState({
tags: this.props.tags,
selectedTag,
});
}
if (prevProps.selectedRegions !== this.props.selectedRegions && this.props.selectedRegions.length > 0) {
this.setState({
selectedTag: null,
});
}
}
public render() {
const dark: ICustomizations = {
settings: {
theme: getDarkTheme(),
},
scopedSettings: {},
};
const { selectedTag } = this.state;
const selectedTagRef = selectedTag ? this.tagItemRefs.get(selectedTag.name).getTagNameRef() : null;
return (
<div className="tag-input">
<div className="tag-input-header p-2">
@ -130,10 +196,18 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
<FontIcon iconName="Search" />
</div>
}
{this.getColorPickerPortal()}
{this.getTagFieldPortal()}
<div className="tag-input-items">
{this.renderTagItems()}
<Customizer {...dark}>
<ContextualMenu
className="tag-input-contextual-menu"
items={this.getContextualMenuItems()}
hidden={!selectedTagRef || this.state.tagOperation !== TagOperationMode.ContextualMenu}
target={selectedTagRef}
onDismiss={this.onHideContextualMenu}
/>
</Customizer>
{this.getColorPickerPortal()}
</div>
{
this.state.addTags &&
@ -156,26 +230,6 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
);
}
public componentDidUpdate(prevProps: ITagInputProps) {
if (prevProps.tags !== this.props.tags) {
let selectedTag = this.state.selectedTag;
if (selectedTag) {
selectedTag = this.props.tags.find((tag) => this.isNameEqual(tag, selectedTag));
}
this.setState({
tags: this.props.tags,
selectedTag,
});
}
if (prevProps.selectedRegions !== this.props.selectedRegions && this.props.selectedRegions.length > 0) {
this.setState({
selectedTag: null,
});
}
}
public triggerNewTagBlur() {
if (this.inputRef.current) {
this.inputRef.current.blur();
@ -188,17 +242,11 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
}
private onEditTag = (tag: ITag) => {
const { editingTag } = this.state;
const newEditingTag = (editingTag && this.isNameEqual(editingTag, tag)) ? null : tag;
const tagOperation = this.state.tagOperation === TagOperationMode.Rename
? TagOperationMode.None : TagOperationMode.Rename;
this.setState({
editingTag: newEditingTag,
editingTagNode: this.getTagNode(newEditingTag),
tagOperation,
});
if (this.state.clickedColor) {
this.setState({
showColorPicker: !this.state.showColorPicker,
});
}
}
private onLockTag = (tag: ITag) => {
@ -232,19 +280,16 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
}
private handleColorChange = (color: string) => {
const tag = this.state.editingTag;
const tag = this.state.selectedTag;
const tags = this.state.tags.map((t) => {
return (this.isNameEqual(t, tag)) ? {
name: t.name,
...tag,
color,
type: t.type,
format: t.format,
} : t;
});
this.setState({
tags,
editingTag: null,
showColorPicker: false,
tagOperation: TagOperationMode.None,
}, () => this.props.onChange(tags));
}
@ -288,7 +333,6 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
});
this.setState({
tags,
editingTag: null,
selectedTag: newTag,
}, () => {
this.props.onChange(tags);
@ -303,16 +347,18 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
}
private getColorPickerPortal = () => {
const { selectedTag } = this.state;
const showColorPicker = this.state.tagOperation === TagOperationMode.ColorPicker;
return (
<AlignPortal align={this.getColorAlignConfig()} target={this.getEditingTagNode}>
<AlignPortal align={this.getColorAlignConfig()} target={this.getSelectedTagNode}>
<div className="tag-input-portal">
{
this.state.showColorPicker &&
showColorPicker &&
<ColorPicker
color={this.state.editingTag && this.state.editingTag.color}
color={selectedTag && selectedTag.color}
colors={tagColors}
onEditColor={this.handleColorChange}
show={this.state.showColorPicker}
show={showColorPicker}
/>
}
</div>
@ -320,25 +366,8 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
);
}
private getTagFieldPortal = () => {
return (
<Align align={this.getFieldAlignConfig()} target={this.getEditingTagNameNode} monitorWindowResize={true}>
<div className="tag-input-portal">
{
this.state.showDropDown &&
<TagContextMenu
key={this.state.editingTag.name}
tag={this.state.editingTag}
onChange={this.props.onTagChanged}
/>
}
</div>
</Align>
);
}
private getColorAlignConfig = () => {
const coords = this.getEditingTagCoords();
const coords = this.colorPickerNode.getBoundingClientRect();
const isNearBottom = coords && coords.top > (window.innerHeight / 2);
const alignCorner = isNearBottom ? "b" : "t";
const verticalOffset = isNearBottom ? 6 : -6;
@ -350,28 +379,8 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
};
}
private getFieldAlignConfig = () => {
return {
// Align top right of source node (dropdown) with top left of target node (tag name row)
points: ["tr", "br"],
// Offset source node by 0px in x and 3px in y
offset: [0, 3],
// Auto adjust position when source node is overflowed
overflow: {adjustX: true, adjustY: true},
};
}
private getEditingTagCoords = () => {
const node = this.state.editingTagNode;
return (node) ? node.getBoundingClientRect() : null;
}
private getEditingTagNode = () => {
return this.state.editingTagNode || document;
}
private getEditingTagNameNode = () => {
return TagInputItem.getNameNode(this.state.editingTagNode) || document;
private getSelectedTagNode = () => {
return this.getTagNode(this.state.selectedTag);
}
private renderTagItems = () => {
@ -385,23 +394,16 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
return props.map((prop) =>
<TagInputItem
{...prop}
key={prop.tag.name}
labels={this.setTagLabels(prop.tag.name)}
ref={(item) => this.setTagItemRef(item, prop.tag)}
onLabelEnter={this.props.onLabelEnter}
onLabelLeave={this.props.onLabelLeave}
onTagChanged={this.props.onTagChanged}
onCallDropDown = {this.handleTagItemDropDown}
{...prop}
/>);
}
private handleTagItemDropDown = () => {
this.setState((prevState) => ({
showDropDown: !prevState.showDropDown,
}));
}
private setTagItemRef = (item: TagInputItem, tag: ITag) => {
this.tagItemRefs.set(tag.name, item);
return item;
@ -412,19 +414,20 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
}
private createTagItemProps = (): ITagInputItemProps[] => {
const tags = this.state.tags;
const { tags, selectedTag, tagOperation } = this.state;
const selectedRegionTagSet = this.getSelectedRegionTagSet();
return tags.map((tag) => (
{
tag,
index: tags.findIndex((t) => this.isNameEqual(t, tag)),
isLocked: this.props.lockedTags &&
this.props.lockedTags.findIndex((str) => this.isNameEqualTo(tag, str)) > -1,
isBeingEdited: this.state.editingTag && this.isNameEqual(this.state.editingTag, tag),
isSelected: this.state.selectedTag && this.isNameEqual(this.state.selectedTag, tag),
isLocked: this.props.lockedTags
&& this.props.lockedTags.findIndex((str) => this.isNameEqualTo(tag, str)) > -1,
isRenaming: selectedTag && this.isNameEqual(selectedTag, tag)
&& tagOperation === TagOperationMode.Rename,
isSelected: selectedTag && this.isNameEqual(this.state.selectedTag, tag),
appliedToSelectedRegions: selectedRegionTagSet.has(tag.name),
onClick: this.handleClick,
onClick: this.onTagItemClick,
onChange: this.updateTag,
} as ITagInputItemProps
));
@ -442,60 +445,53 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
return result;
}
private onAltClick = (tag: ITag) => {
const { editingTag } = this.state;
const newEditingTag = this.state.showDropDown && editingTag && this.isNameEqual(editingTag, tag) ? null : tag;
this.setState({
editingTag: newEditingTag,
editingTagNode: this.getTagNode(newEditingTag),
});
}
private onSingleClick = (tag: ITag, clickedColor: boolean, clickedDropDown: boolean) => {
const { editingTag, selectedTag } = this.state;
const newEditingTag = this.state.showDropDown && editingTag && this.isNameEqual(editingTag, tag) ? null : tag;
this.setState({
editingTag: newEditingTag,
editingTagNode: this.getTagNode(newEditingTag),
clickedColor,
clickedDropDown,
showColorPicker: !this.state.showColorPicker && clickedColor,
showDropDown: !this.state.showDropDown && clickedDropDown,
selectedTag: clickedDropDown ? tag : selectedTag,
});
}
private handleClick = (tag: ITag, props: ITagClickProps) => {
private onTagItemClick = (tag: ITag, props: ITagClickProps) => {
if (props.ctrlKey && this.props.onCtrlTagClick) { // Lock tags
this.props.onCtrlTagClick(tag);
this.setState({ clickedColor: props.clickedColor, clickedDropDown: props.clickedDropDown });
} else if (props.altKey) { // Edit tag
this.onAltClick(tag);
} else if (props.keyClick) {
this.onSingleClick(tag, props.clickedColor, props.clickedDropDown);
} else { // Select tag
const { editingTag, selectedTag } = this.state;
const inEditMode = editingTag && this.isNameEqual(editingTag, tag);
const alreadySelected = selectedTag && this.isNameEqual(selectedTag, tag);
const newEditingTag = inEditMode ? null : editingTag;
this.setState({
editingTag: newEditingTag,
editingTagNode: this.getTagNode(newEditingTag),
selectedTag: (alreadySelected && !inEditMode) ? null : tag,
clickedColor: props.clickedColor,
clickedDropDown: props.clickedDropDown,
showColorPicker: false,
showDropDown: false,
selectedTag: tag,
tagOperation: TagOperationMode.Rename,
});
} else if (props.clickedDropDown) {
const { selectedTag } = this.state;
const showContextualMenu = !selectedTag || !this.isNameEqual(selectedTag, tag)
|| this.state.tagOperation !== TagOperationMode.ContextualMenu;
const tagOperation = showContextualMenu ? TagOperationMode.ContextualMenu : TagOperationMode.None;
this.setState({
selectedTag: tag,
tagOperation,
});
} else if (props.clickedColor) {
const { selectedTag, tagOperation } = this.state;
const showColorPicker = tagOperation !== TagOperationMode.ColorPicker;
const newTagOperation = showColorPicker ? TagOperationMode.ColorPicker : TagOperationMode.None;
if (showColorPicker) {
this.colorPickerNode = this.getTagNode(tag);
}
this.setState({
selectedTag: showColorPicker ? tag : selectedTag,
tagOperation: newTagOperation,
});
} else { // Select tag
const { selectedTag, tagOperation: oldTagOperation } = this.state;
const selected = selectedTag && this.isNameEqual(selectedTag, tag);
const tagOperation = selected ? oldTagOperation : TagOperationMode.None;
let deselect = selected && oldTagOperation === TagOperationMode.None;
// Only fire click event if a region is selected
if (this.props.selectedRegions &&
this.props.selectedRegions.length > 0 &&
this.props.onTagClick &&
!inEditMode) {
this.props.onTagClick) {
deselect = false;
this.props.onTagClick(tag);
}
this.setState({
selectedTag: deselect ? null : tag,
tagOperation,
});
}
}
@ -585,4 +581,107 @@ export class TagInput extends React.Component<ITagInputProps, ITagInputState> {
private isNameEqualTo = (tag: ITag, str: string) => {
return tag.name.trim().toLocaleLowerCase() === str.trim().toLocaleLowerCase();
}
private onHideContextualMenu = () => {
this.setState({tagOperation: TagOperationMode.None});
}
private getContextualMenuItems = (): IContextualMenuItem[] => {
const tag = this.state.selectedTag;
if (!tag) {
return [];
}
const menuItems: IContextualMenuItem[] = [
{
key: "type",
iconProps: {
iconName: "Link",
},
text: tag.type ? tag.type : strings.tags.toolbar.type,
subMenuProps: {
items: this.getTypeSubMenuItems(),
},
},
{
key: "format",
iconProps: {
iconName: "Link",
},
text: tag.format ? tag.format : strings.tags.toolbar.format,
subMenuProps: {
items: this.getFormatSubMenuItems(),
},
},
];
return menuItems;
}
private getTypeSubMenuItems = (): IContextualMenuItem[] => {
const tag = this.state.selectedTag;
const types = Object.values(FieldType);
return types.map((type) => {
return {
key: type,
text: type,
canCheck: true,
isChecked: type === tag.type,
onClick: this.onTypeSelect,
} as IContextualMenuItem;
});
}
private getFormatSubMenuItems = (): IContextualMenuItem[] => {
const tag = this.state.selectedTag;
const formats = filterFormat(tag.type);
return formats.map((format) => {
return {
key: format,
text: format,
canCheck: true,
isChecked: format === tag.format,
onClick: this.onFormatSelect,
} as IContextualMenuItem;
});
}
private onTypeSelect = (event: React.MouseEvent<HTMLButtonElement>, item?: IContextualMenuItem): void => {
event.preventDefault();
const type = item.text as FieldType;
const tag = this.state.selectedTag;
if (type === tag.type) {
return;
}
const newTag = {
...tag,
type,
format: FieldFormat.NotSpecified,
};
if (this.props.onTagChanged) {
this.props.onTagChanged(tag, newTag);
}
}
private onFormatSelect = (event: React.MouseEvent<HTMLButtonElement>, item?: IContextualMenuItem): void => {
event.preventDefault();
const format = item.text as FieldFormat;
const tag = this.state.selectedTag;
if (format === tag.format) {
return;
}
const newTag = {
...tag,
format,
};
if (this.props.onTagChanged) {
this.props.onTagChanged(tag, newTag);
}
}
}

Просмотреть файл

@ -13,7 +13,7 @@ describe("Tag Input Item", () => {
tag: MockFactory.createTestTag(),
index: 0,
labels: [],
isBeingEdited: false,
isRenaming: false,
isLocked: false,
isSelected: false,
appliedToSelectedRegions: false,
@ -21,7 +21,6 @@ describe("Tag Input Item", () => {
onChange: jest.fn(),
onLabelEnter: jest.fn(),
onLabelLeave: jest.fn(),
onCallDropDown: jest.fn(),
};
}

Просмотреть файл

@ -7,12 +7,6 @@ import { ITag, ILabel, FieldType, FieldFormat } from "../../../../models/applica
import { strings } from "../../../../common/strings";
import TagInputItemLabel from "./tagInputItemLabel";
export enum TagEditMode {
Color = "color",
Name = "name",
Dropdown = "inputField",
}
export interface ITagClickProps {
ctrlKey?: boolean;
altKey?: boolean;
@ -31,8 +25,8 @@ export interface ITagInputItemProps {
index: number;
/** Labels owned by the tag */
labels: ILabel[];
/** Tag is currently being edited */
isBeingEdited: boolean;
/** Tag is currently renaming */
isRenaming: boolean;
/** Tag is currently locked for application */
isLocked: boolean;
/** Tag is currently selected */
@ -46,39 +40,30 @@ export interface ITagInputItemProps {
onLabelEnter: (label: ILabel) => void;
onLabelLeave: (label: ILabel) => void;
onTagChanged?: (oldTag: ITag, newTag: ITag) => void;
onCallDropDown: () => void;
}
export interface ITagInputItemState {
/** Tag is currently being edited */
isBeingEdited: boolean;
/** Tag is currently renaming */
isRenaming: boolean;
/** Tag is currently locked for application */
isLocked: boolean;
/** Mode of tag editing (text or color) */
tagEditMode: TagEditMode;
}
export default class TagInputItem extends React.Component<ITagInputItemProps, ITagInputItemState> {
public static getNameNode(tagNode: Element): Element | undefined {
if (tagNode) {
return tagNode.getElementsByClassName(TagInputItem.TAG_NAME_CLASS_NAME)[0];
}
return undefined;
}
private static TAG_NAME_CLASS_NAME = "tag-item";
public state: ITagInputItemState = {
isBeingEdited: false,
isRenaming: false,
isLocked: false,
tagEditMode: null,
};
private itemRef = React.createRef<HTMLDivElement>();
public render() {
const style: any = {
background: this.props.tag.color,
};
return (
<div className={"tag-item-block"}>
<div
@ -89,7 +74,10 @@ export default class TagInputItem extends React.Component<ITagInputItemProps, IT
<div className={"tag-item-block-2"}>
{
this.props.tag &&
<div className={this.getItemClassName()} style={style}>
<div
ref={this.itemRef}
className={this.getItemClassName()}
style={style}>
<div
className={"tag-content pr-2"}
onClick={this.onNameClick}>
@ -108,9 +96,9 @@ export default class TagInputItem extends React.Component<ITagInputItemProps, IT
}
public componentDidUpdate(prevProps: ITagInputItemProps) {
if (prevProps.isBeingEdited !== this.props.isBeingEdited) {
if (prevProps.isRenaming !== this.props.isRenaming) {
this.setState({
isBeingEdited: this.props.isBeingEdited,
isRenaming: this.props.isRenaming,
});
}
@ -121,21 +109,24 @@ export default class TagInputItem extends React.Component<ITagInputItemProps, IT
}
}
private onInputFieldClick = (e: any) => {
public getTagNameRef() {
return this.itemRef;
}
private onDropdownClick = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
this.setState({
tagEditMode: TagEditMode.Dropdown,
}, () => this.props.onClick(this.props.tag, { keyClick: true, clickedDropDown: true }));
const clickedDropDown = true;
this.props.onClick(this.props.tag, { clickedDropDown });
}
private onColorClick = (e: MouseEvent) => {
e.stopPropagation();
const ctrlKey = e.ctrlKey || e.metaKey;
const keyClick = (e.type === "click");
this.setState({
tagEditMode: TagEditMode.Color,
}, () => this.props.onClick(this.props.tag, { ctrlKey, keyClick, clickedColor: true }));
const altKey = e.altKey;
const clickedColor = true;
this.props.onClick(this.props.tag, { ctrlKey, altKey, clickedColor });
}
private onNameClick = (e: MouseEvent) => {
@ -143,13 +134,11 @@ export default class TagInputItem extends React.Component<ITagInputItemProps, IT
const ctrlKey = e.ctrlKey || e.metaKey;
const altKey = e.altKey;
this.setState({
tagEditMode: TagEditMode.Name,
}, () => this.props.onClick(this.props.tag, { ctrlKey, altKey }));
this.props.onClick(this.props.tag, { ctrlKey, altKey });
}
private getItemClassName = () => {
const classNames = [TagInputItem.TAG_NAME_CLASS_NAME];
const classNames = ["tag-item"];
if (this.props.isSelected) {
classNames.push("tag-item-selected");
}
@ -169,7 +158,7 @@ export default class TagInputItem extends React.Component<ITagInputItemProps, IT
}
<div className="tag-name-body">
{
(this.state.isBeingEdited && this.state.tagEditMode === TagEditMode.Name)
this.state.isRenaming
?
<input
className={`tag-name-editor ${this.getContentClassName()}`}
@ -196,7 +185,7 @@ export default class TagInputItem extends React.Component<ITagInputItemProps, IT
ariaLabel={strings.tags.toolbar.contextualMenu}
className="tag-input-toolbar-iconbutton ml-2"
iconProps={{iconName: "ChevronDown"}}
onClick={this.onInputFieldClick} />
onClick={this.onDropdownClick} />
</div>
</div>
);
@ -221,16 +210,13 @@ export default class TagInputItem extends React.Component<ITagInputItemProps, IT
});
} else if (e.key === "Escape") {
this.setState({
isBeingEdited: false,
isRenaming: false,
});
}
}
private getContentClassName = () => {
const classNames = ["tag-name-text px-2 pb-1"];
if (this.state.isBeingEdited && this.state.tagEditMode === TagEditMode.Color) {
classNames.push("tag-color-edit");
}
if (this.isTypeOrFormatSpecified()) {
classNames.push("tag-name-text-typed");
}
@ -243,7 +229,7 @@ export default class TagInputItem extends React.Component<ITagInputItemProps, IT
return (displayIndex < 10) ? displayIndex : null;
}
private isTypeOrFormatSpecified() {
private isTypeOrFormatSpecified = () => {
const {tag} = this.props;
return (tag.type && tag.type !== FieldType.String) ||
(tag.format && tag.format !== FieldFormat.NotSpecified);

Просмотреть файл

@ -7,3 +7,4 @@ $tagLinkWidth: 22px;
$tagItemWidth: $tagInputWidth - $tagColorWidth;
$tagTextWidth: $tagItemWidth - 55px;
$tagTextLinkedWidth: $tagTextWidth - $tagLinkWidth;
$tagContextualMenuWidth: $tagItemWidth - 8px;

Просмотреть файл

@ -19,6 +19,7 @@ export function registerIcons() {
Settings: "\uE713",
Link: "\uE71B",
Search: "\uE721",
CheckMark: "\uE73E",
Up: "\uE74A",
Down: "\uE74B",
Delete: "\uE74D",