Skip to content

Commit a08c14b

Browse files
committed
feat(TagsInput): add TagsInput component
1 parent 0cc68c2 commit a08c14b

File tree

7 files changed

+658
-0
lines changed

7 files changed

+658
-0
lines changed

packages/react-ui/src/components/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export * from "./separator/separator";
3333
export * from "./spinner/spinner";
3434
export * from "./switch/switch";
3535
export * from "./tabs/tabs";
36+
export * from "./tags-input/tags-input";
3637
export * from "./toast/toast";
3738
export * from "./toast/use-toast";
3839
export * from "./tooltip/tooltip";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { useListItem } from "@floating-ui/react";
2+
import type { ClipboardEvent, FocusEvent, KeyboardEvent, SyntheticEvent } from "react";
3+
import { useMergeRefs } from "../../hooks";
4+
import type { PrimitiveProps } from "../../primitives";
5+
import { useComposite } from "../../primitives/composite/composite-context";
6+
import { tx } from "../../utils";
7+
8+
type TagsInputInputProps = {
9+
delimiter: string | RegExp;
10+
addOnBlur: boolean;
11+
addOnPaste: boolean;
12+
onAdd: (value: string | string[]) => boolean;
13+
onDelete: (index: number) => void;
14+
};
15+
16+
export const TagsInputInput = (props: PrimitiveProps<"input", TagsInputInputProps, "type" | "tabIndex">) => {
17+
const { className, onFocus, delimiter, addOnBlur, addOnPaste, onAdd, onDelete, ref, ...rest } = props;
18+
19+
const { ref: itemRef, index } = useListItem();
20+
const { activeIndex, setActiveIndex } = useComposite();
21+
22+
const refs = useMergeRefs(ref, itemRef);
23+
24+
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
25+
if (addOnBlur) {
26+
if (e.target.value && onAdd(e.target.value)) {
27+
e.target.value = "";
28+
}
29+
}
30+
31+
setActiveIndex(undefined);
32+
};
33+
34+
const handleFocus = (e: FocusEvent<HTMLInputElement>) => {
35+
setActiveIndex(index);
36+
37+
onFocus?.(e);
38+
};
39+
40+
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
41+
if (!addOnPaste) {
42+
return;
43+
}
44+
45+
e.preventDefault();
46+
47+
if (!e.clipboardData) {
48+
return;
49+
}
50+
51+
const text = e.clipboardData.getData("text").trim();
52+
53+
if (!text) {
54+
return;
55+
}
56+
57+
if (delimiter) {
58+
onAdd(
59+
text
60+
.split(delimiter)
61+
.map((v) => v.trim())
62+
.filter(Boolean),
63+
);
64+
} else {
65+
onAdd(text);
66+
}
67+
};
68+
69+
const handleInput = (e: SyntheticEvent<HTMLInputElement, InputEvent>) => {
70+
if (!e.nativeEvent.data) {
71+
return;
72+
}
73+
74+
if (e.nativeEvent.data == delimiter || (delimiter instanceof RegExp && delimiter.test(e.nativeEvent.data))) {
75+
const value = e.currentTarget.value.replaceAll(delimiter, "").trim();
76+
77+
if (value && onAdd(value)) {
78+
e.currentTarget.value = "";
79+
}
80+
}
81+
};
82+
83+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
84+
if (e.defaultPrevented) {
85+
return;
86+
}
87+
88+
if (e.nativeEvent.isComposing) {
89+
return;
90+
}
91+
92+
if (e.key == "ArrowLeft") {
93+
if (e.currentTarget.selectionStart != 0) {
94+
e.stopPropagation();
95+
}
96+
97+
return;
98+
}
99+
100+
if (e.key == "Enter") {
101+
if (!e.currentTarget.value) {
102+
return;
103+
}
104+
105+
const value = e.currentTarget.value.trim();
106+
107+
if (!value) {
108+
return;
109+
}
110+
111+
if (onAdd(value)) {
112+
e.currentTarget.value = "";
113+
}
114+
115+
return;
116+
}
117+
118+
if (e.key == "Delete" || e.key == "Backspace") {
119+
if (e.currentTarget.selectionStart !== 0 || e.currentTarget.selectionEnd !== 0) {
120+
return;
121+
}
122+
123+
if (activeIndex != undefined && activeIndex < index) {
124+
onDelete(activeIndex);
125+
}
126+
127+
if (e.key == "Backspace") {
128+
setActiveIndex((idx) => (idx ? idx - 1 : index));
129+
}
130+
131+
return;
132+
}
133+
134+
setActiveIndex(index);
135+
};
136+
137+
return (
138+
<input
139+
ref={refs}
140+
type={"text"}
141+
autoComplete={"off"}
142+
autoCapitalize={"off"}
143+
autoCorrect={"off"}
144+
onBlur={handleBlur}
145+
onFocus={handleFocus}
146+
onPaste={handlePaste}
147+
onInput={handleInput}
148+
onKeyDown={handleKeyDown}
149+
className={tx("min-w-20 flex-1 outline-none", className)}
150+
data-index={index}
151+
{...rest}
152+
/>
153+
);
154+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useListItem } from "@floating-ui/react";
2+
import type { PrimitiveProps } from "../../primitives";
3+
import { useComposite } from "../../primitives/composite/composite-context";
4+
import { ariaAttr, tx } from "../../utils";
5+
import { CloseButton } from "../close-button/close-button";
6+
7+
type TagsInputItemProps = {
8+
name?: string;
9+
value: string;
10+
disabled: boolean;
11+
onDelete: (index: number) => void;
12+
};
13+
14+
export const TagsInputItem = (props: PrimitiveProps<"div", TagsInputItemProps, "ref" | "children">) => {
15+
const { name, value, disabled, onDelete, className, ...rest } = props;
16+
17+
const { ref: itemRef, index } = useListItem();
18+
const { activeIndex } = useComposite();
19+
20+
const selected = activeIndex === index;
21+
22+
const handelDelete = () => {
23+
onDelete(index);
24+
};
25+
26+
return (
27+
<div
28+
ref={itemRef}
29+
aria-disabled={ariaAttr(disabled)}
30+
aria-selected={ariaAttr(selected)}
31+
data-index={index}
32+
className={tx(
33+
"inline-flex items-center gap-1 rounded-md pe-1",
34+
selected ? "bg-bg-subtle" : "bg-bg-subtlest",
35+
className,
36+
)}
37+
{...rest}
38+
>
39+
{value}
40+
{name && <input type={"hidden"} disabled={disabled} tabIndex={-1} name={`${name}[]`} value={value} />}
41+
<CloseButton
42+
radius
43+
disabled={disabled}
44+
className={"pointer-events-auto h-full"}
45+
onClick={handelDelete}
46+
tabIndex={-1}
47+
noPadding
48+
aria-label={"删除标签"}
49+
size={"1em"}
50+
/>
51+
</div>
52+
);
53+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export const tagsInputSizeStyles = {
2+
xs: {
3+
root: "px-1",
4+
item: "ps-1.5",
5+
input: "px-1.5",
6+
},
7+
sm: {
8+
root: "px-1",
9+
item: "ps-1.5",
10+
input: "px-1.5",
11+
},
12+
md: {
13+
root: "px-1.5",
14+
item: "ps-1.5",
15+
input: "px-2",
16+
},
17+
lg: {
18+
root: "px-1.5",
19+
item: "ps-2",
20+
input: "px-2",
21+
},
22+
xl: {
23+
root: "px-1.5",
24+
item: "ps-2",
25+
input: "px-2",
26+
},
27+
};

0 commit comments

Comments
 (0)