feat(ai-character): 시리즈 키워드 칩 입력 적용

This commit is contained in:
Yu Sung
2026-08-04 15:20:09 +09:00
parent d030d2eebd
commit bd37e05d82
9 changed files with 91 additions and 16 deletions

View File

@@ -0,0 +1,36 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { TagInput } from "@/shared/ui/tag-input";
test("TagInput creates no chip from blank drafts", () => {
// Given
const onChange = vi.fn();
render(<TagInput error={undefined} errorId="keyword-error" label="키워드" onChange={onChange} value="" />);
// When
fireEvent.change(screen.getByLabelText("키워드"), { target: { value: " " } });
fireEvent.keyDown(screen.getByLabelText("키워드"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("키워드"), { target: { value: "\t" } });
fireEvent.keyDown(screen.getByLabelText("키워드"), { key: "," });
// Then
expect(screen.queryByRole("button", { name: /삭제/ })).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
test("TagInput emits only the remaining comma-separated value when removing a committed chip", () => {
// Given
const onChange = vi.fn();
render(<TagInput error={undefined} errorId="keyword-error" label="키워드" onChange={onChange} value="달빛,상담" />);
const removeButton = screen.getByRole("button", { name: "키워드 달빛 삭제" });
// When
fireEvent.click(removeButton);
// Then
expect(removeButton.querySelector("svg[aria-hidden='true']")).toBeInTheDocument();
expect(removeButton).not.toHaveTextContent("삭제");
expect(onChange).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith("상담");
});

View File

@@ -0,0 +1,59 @@
import { useId, useState } from "react";
export type TagInputProps = {
readonly error: string | undefined;
readonly errorId: string;
readonly label: string;
readonly onChange: (value: string) => void;
readonly value: string;
};
export function TagInput({ error, errorId, label, onChange, value }: TagInputProps) {
const [draft, setDraft] = useState("");
const inputId = useId();
const helpId = `${inputId}-help`;
const tags = value.split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0);
function commitDraft() {
const nextTag = draft.trim();
if (nextTag.length === 0) {
return;
}
onChange([...tags, nextTag].join(","));
setDraft("");
}
function changeDraft(nextDraft: string) {
const parts = nextDraft.split(",");
if (parts.length === 1) {
setDraft(nextDraft);
return;
}
const committedTags = parts.slice(0, -1).map((tag) => tag.trim()).filter((tag) => tag.length > 0);
if (committedTags.length > 0) {
onChange([...tags, ...committedTags].join(","));
}
setDraft(parts[parts.length - 1] ?? "");
}
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
<div className={`flex min-h-11 flex-wrap items-center gap-2 rounded-md border bg-card px-2 py-1 text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 ${error === undefined ? "border-input" : "border-destructive"}`}>
{tags.map((tag, index) => (
<span className="flex min-h-11 items-center rounded-md bg-muted pl-3 text-sm font-semibold" key={`${tag}-${index}`}>
{tag}
<button aria-label={`${label} ${tag} 삭제`} className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => onChange(tags.filter((_, tagIndex) => tagIndex !== index).join(","))} type="button">
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</svg>
</button>
</span>
))}
<input aria-describedby={`${helpId}${error === undefined ? "" : ` ${errorId}`}`} aria-invalid={error === undefined ? undefined : true} className="min-h-11 min-w-32 flex-1 bg-transparent px-2 text-base font-normal text-foreground outline-none" id={inputId} onChange={(event) => changeDraft(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); commitDraft(); } }} value={draft} />
</div>
<p className="text-sm text-muted-foreground" id={helpId}> Enter로 {label} .</p>
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</div>
);
}