Skip to content

Commit f5b6595

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

File tree

7 files changed

+663
-0
lines changed

7 files changed

+663
-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,163 @@
1+
import { useListItem } from "@floating-ui/react";
2+
import type { ChangeEvent, ClipboardEvent, FocusEvent, KeyboardEvent, SyntheticEvent } from "react";
3+
import { useControllableState, 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+
value: string | undefined;
15+
onChange?: (value: string) => void;
16+
};
17+
18+
export const TagsInputInput = (props: PrimitiveProps<"input", TagsInputInputProps, "type" | "tabIndex">) => {
19+
const { className, onFocus, delimiter, addOnBlur, addOnPaste, onAdd, onDelete, value, onChange, ref, ...rest } =
20+
props;
21+
22+
const { ref: itemRef, index } = useListItem();
23+
const { activeIndex, setActiveIndex } = useComposite();
24+
const [valueState, setValueState] = useControllableState({ value, defaultValue: "", onChange });
25+
26+
const refs = useMergeRefs(ref, itemRef);
27+
28+
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
29+
if (addOnBlur) {
30+
if (e.target.value && onAdd(e.target.value)) {
31+
setValueState("");
32+
}
33+
}
34+
35+
setActiveIndex(undefined);
36+
};
37+
38+
const handleFocus = (e: FocusEvent<HTMLInputElement>) => {
39+
setActiveIndex(index);
40+
41+
onFocus?.(e);
42+
};
43+
44+
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
45+
if (!addOnPaste) {
46+
return;
47+
}
48+
49+
e.preventDefault();
50+
51+
if (!e.clipboardData) {
52+
return;
53+
}
54+
55+
const text = e.clipboardData.getData("text").trim();
56+
57+
if (!text) {
58+
return;
59+
}
60+
61+
if (delimiter) {
62+
onAdd(
63+
text
64+
.split(delimiter)
65+
.map((v) => v.trim())
66+
.filter(Boolean),
67+
);
68+
} else {
69+
onAdd(text);
70+
}
71+
};
72+
73+
const handleInput = (e: SyntheticEvent<HTMLInputElement, InputEvent>) => {
74+
if (!e.nativeEvent.data) {
75+
return;
76+
}
77+
78+
if (e.nativeEvent.data == delimiter || (delimiter instanceof RegExp && delimiter.test(e.nativeEvent.data))) {
79+
const value = e.currentTarget.value.replaceAll(delimiter, "").trim();
80+
81+
if (value && onAdd(value)) {
82+
setValueState("");
83+
}
84+
}
85+
};
86+
87+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
88+
if (e.defaultPrevented) {
89+
return;
90+
}
91+
92+
if (e.nativeEvent.isComposing) {
93+
return;
94+
}
95+
96+
if (e.key == "ArrowLeft") {
97+
if (e.currentTarget.selectionStart != 0) {
98+
e.stopPropagation();
99+
}
100+
101+
return;
102+
}
103+
104+
if (e.key == "Enter") {
105+
if (!e.currentTarget.value) {
106+
return;
107+
}
108+
109+
const value = e.currentTarget.value.trim();
110+
111+
if (!value) {
112+
return;
113+
}
114+
115+
if (onAdd(value)) {
116+
setValueState("");
117+
}
118+
119+
return;
120+
}
121+
122+
if (e.key == "Delete" || e.key == "Backspace") {
123+
if (e.currentTarget.selectionStart !== 0 || e.currentTarget.selectionEnd !== 0) {
124+
return;
125+
}
126+
127+
if (activeIndex != undefined && activeIndex < index) {
128+
onDelete(activeIndex);
129+
}
130+
131+
if (e.key == "Backspace") {
132+
setActiveIndex((idx) => (idx ? idx - 1 : index));
133+
}
134+
135+
return;
136+
}
137+
138+
setActiveIndex(index);
139+
};
140+
141+
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
142+
setValueState(e.currentTarget.value);
143+
};
144+
145+
return (
146+
<input
147+
ref={refs}
148+
type={"text"}
149+
autoComplete={"off"}
150+
autoCapitalize={"off"}
151+
autoCorrect={"off"}
152+
onBlur={handleBlur}
153+
onFocus={handleFocus}
154+
onPaste={handlePaste}
155+
onInput={handleInput}
156+
onKeyDown={handleKeyDown}
157+
className={tx("min-w-20 flex-1 outline-none", className)}
158+
value={valueState}
159+
onChange={handleChange}
160+
{...rest}
161+
/>
162+
);
163+
};
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)