feat(ai-character): 시리즈 키워드 칩 입력 적용
This commit is contained in:
36
src/shared/ui/__tests__/tag-input.test.tsx
Normal file
36
src/shared/ui/__tests__/tag-input.test.tsx
Normal 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("상담");
|
||||
});
|
||||
59
src/shared/ui/tag-input.tsx
Normal file
59
src/shared/ui/tag-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user