Skip to content

Commit c782336

Browse files
committed
Implement a new component to input run tags
1 parent d302622 commit c782336

File tree

2 files changed

+137
-3
lines changed

2 files changed

+137
-3
lines changed

apps/webapp/app/components/runs/v3/RunTag.tsx

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,21 @@ import tagLeftPath from "./tag-left.svg";
33
import { SimpleTooltip } from "~/components/primitives/Tooltip";
44
import { Link } from "@remix-run/react";
55
import { cn } from "~/utils/cn";
6-
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
6+
import { ClipboardCheckIcon, ClipboardIcon, XIcon } from "lucide-react";
77

88
type Tag = string | { key: string; value: string };
99

10-
export function RunTag({ tag, to, tooltip }: { tag: string; to?: string; tooltip?: string }) {
10+
export function RunTag({
11+
tag,
12+
to,
13+
tooltip,
14+
action = { type: "copy" },
15+
}: {
16+
tag: string;
17+
action?: { type: "copy" } | { type: "delete"; onDelete: (tag: string) => void };
18+
to?: string;
19+
tooltip?: string;
20+
}) {
1121
const tagResult = useMemo(() => splitTag(tag), [tag]);
1222
const [isHovered, setIsHovered] = useState(false);
1323

@@ -57,7 +67,11 @@ export function RunTag({ tag, to, tooltip }: { tag: string; to?: string; tooltip
5767
return (
5868
<div className="group relative inline-flex shrink-0" onMouseLeave={() => setIsHovered(false)}>
5969
{tagContent}
60-
<CopyButton textToCopy={tag} isHovered={isHovered} />
70+
{action.type === "delete" ? (
71+
<DeleteButton tag={tag} onDelete={action.onDelete} isHovered={isHovered} />
72+
) : (
73+
<CopyButton textToCopy={tag} isHovered={isHovered} />
74+
)}
6175
</div>
6276
);
6377
}
@@ -105,6 +119,45 @@ function CopyButton({ textToCopy, isHovered }: { textToCopy: string; isHovered:
105119
);
106120
}
107121

122+
function DeleteButton({
123+
tag,
124+
onDelete,
125+
isHovered,
126+
}: {
127+
tag: string;
128+
onDelete: (tag: string) => void;
129+
isHovered: boolean;
130+
}) {
131+
const handleDelete = useCallback(
132+
(e: React.MouseEvent) => {
133+
e.preventDefault();
134+
e.stopPropagation();
135+
onDelete(tag);
136+
},
137+
[tag, onDelete]
138+
);
139+
140+
return (
141+
<SimpleTooltip
142+
button={
143+
<span
144+
onClick={handleDelete}
145+
onMouseDown={(e) => e.stopPropagation()}
146+
className={cn(
147+
"absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-charcoal-650 bg-charcoal-750",
148+
isHovered ? "flex" : "hidden",
149+
"text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-rose-400"
150+
)}
151+
>
152+
<XIcon className="size-3.5" />
153+
</span>
154+
}
155+
content="Remove tag"
156+
disableHoverableContent
157+
/>
158+
);
159+
}
160+
108161
/** Takes a string and turns it into a tag
109162
*
110163
* If the string has 12 or fewer alpha characters followed by an underscore or colon then we return an object with a key and value
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useCallback, useState, type KeyboardEvent } from "react";
2+
import { Input } from "~/components/primitives/Input";
3+
import { RunTag } from "./RunTag";
4+
5+
interface TagInputProps {
6+
id?: string; // used for the hidden input for form submission
7+
name?: string; // used for the hidden input for form submission
8+
defaultTags?: string[];
9+
placeholder?: string;
10+
variant?: "small" | "medium";
11+
onTagsChange?: (tags: string[]) => void;
12+
}
13+
14+
export function RunTagInput({
15+
id,
16+
name,
17+
defaultTags = [],
18+
placeholder = "Type and press Enter to add tags",
19+
variant = "small",
20+
onTagsChange,
21+
}: TagInputProps) {
22+
const [tags, setTags] = useState<string[]>(defaultTags);
23+
const [inputValue, setInputValue] = useState("");
24+
25+
const addTag = useCallback(
26+
(tagText: string) => {
27+
const trimmedTag = tagText.trim();
28+
if (trimmedTag && !tags.includes(trimmedTag)) {
29+
const newTags = [...tags, trimmedTag];
30+
setTags(newTags);
31+
onTagsChange?.(newTags);
32+
}
33+
setInputValue("");
34+
},
35+
[tags, onTagsChange]
36+
);
37+
38+
const removeTag = useCallback(
39+
(tagToRemove: string) => {
40+
const newTags = tags.filter((tag) => tag !== tagToRemove);
41+
setTags(newTags);
42+
onTagsChange?.(newTags);
43+
},
44+
[tags, onTagsChange]
45+
);
46+
47+
const handleKeyDown = useCallback(
48+
(e: KeyboardEvent<HTMLInputElement>) => {
49+
if (e.key === "Enter") {
50+
e.preventDefault();
51+
addTag(inputValue);
52+
} else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) {
53+
removeTag(tags[tags.length - 1]);
54+
}
55+
},
56+
[inputValue, addTag, removeTag, tags]
57+
);
58+
59+
return (
60+
<div className="flex flex-col gap-2">
61+
<input type="hidden" name={name} id={id} value={tags.join(",")} />
62+
63+
<Input
64+
type="text"
65+
value={inputValue}
66+
onChange={(e) => setInputValue(e.target.value)}
67+
onKeyDown={handleKeyDown}
68+
placeholder={placeholder}
69+
variant={variant}
70+
/>
71+
72+
{tags.length > 0 && (
73+
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
74+
{tags.map((tag, i) => (
75+
<RunTag key={tag} tag={tag} action={{ type: "delete", onDelete: removeTag }} />
76+
))}
77+
</div>
78+
)}
79+
</div>
80+
);
81+
}

0 commit comments

Comments
 (0)