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

@@ -151,7 +151,7 @@ Primary font stack: `Pretendard`, `Noto Sans KR`, `Apple SD Gothic Neo`, `system
- Accessibility: label targets the input; description, accept guidance, and error are connected with `aria-describedby`; clear is a native button.
- Motion: none.
### TagChipInput
### TagInput
- Structure: controlled comma-separated string boundary that renders committed values as visible wrapping chips and preserves an inline draft input. A visible label, help text, and error text accompany the field.
- Controlled value: the caller owns one comma-separated string. Enter or a comma commits the non-empty draft into that string; each chip's native remove button removes only its value. Duplicate prevention and maximum-count limits are caller policy, not this pattern's policy.

View File

@@ -10,12 +10,12 @@
| 문서 항목 | 내용 |
|---|---|
| 상태 | 2026-08-04 `P4-R10` Audio form UX 회귀 자동 검증 완료. `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17` 자동 보완 완료, 실제 crop pixel·stale ADMIN·Series/FanTalk/Comments/file policy 수동 QA 대기 |
| 상태 | 2026-08-04 `P5-R6` Series form 이미지 순서·keyword chip UX 자동 검증 완료. `P4-R10`, `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17` 자동 보완 완료, 실제 crop pixel·stale ADMIN·Series/FanTalk/Comments/file policy 수동 QA 대기 |
| 최초 작성일 | 2026-07-25 |
| 재작성일 | 2026-07-26 |
| 요구사항 기준 | [prd.md](./prd.md) |
| API 기준 | [api-contract.openapi.json](./api-contract.openapi.json) |
| 현재 활성 Goal | `P4-R10` 자동 검증 완료 (`P4-R9` 완료) |
| 현재 활성 Goal | `P5-R6` 자동 검증 완료 (`P5-R5` 완료) |
## 목표
@@ -2885,6 +2885,30 @@ reorder를 확인한다. genre lookup과 상세 수정용 원본값 제공 후
- **GREEN:** `SeriesForm`에 image selection token, `isImagePreparing`, 준비/crop 중 submit guard, preview reject inline 오류를 추가했다. `image`는 crop 적용 결과만 commit하고, 수정 화면 crop 취소는 기존 서버 image 유지 계약을 보존한다. focused 재실행 결과 `series-form.test.tsx`와 `series-form-crop.test.tsx` 2 files / 13 tests passed였다.
- **REFACTOR/회귀:** crop lifecycle test를 `series-form-crop.test.tsx`로 분리해 `SeriesForm.tsx` 220 LOC, `series-form.test.tsx` 230 LOC, `series-form-crop.test.tsx` 85 LOC로 유지했다. `npm run test:run -- src/features/series` 결과 8 files / 37 tests passed였다. 사용자 지시에 따라 mock E2E는 전체 Task 완료 전까지 보류했다. `npm run typecheck`, `npm run lint`, `npm run build`, `git diff --check`는 모두 exit 0 또는 no output이었다. Series directory LSP diagnostics는 17 TSX files / 오류 0건이었다.
### Task R5.6 — Series 생성 form 이미지·keyword 입력 UX 정렬
**Goal 실행 `P5-R6`:** Series 생성 form의 image 입력을 최상단에 배치하고 Audio tags와 동일한 공용 `TagInput`으로 keyword를 입력하되 기존 comma-separated request 계약을 유지한다.
- **시작 조건:** `P4-R10`, `P5-R5`, `SERIES-014`, 사용자 승인 설계.
- **완료 증거:** image-first field order와 Enter/comma chip·remove 동작의 실패 test, 공용 `TagInput` 재사용, Series comma-separated `keyword` request, Audio tag 회귀, Chromium mock browser와 시각 QA 증거.
- **Files:** Move/Modify: `src/features/audio-contents/components/AudioTagInput.tsx` → `src/shared/ui/tag-input.tsx`; Modify: `src/features/audio-contents/components/AudioContentForm.tsx`, `src/features/series/components/SeriesForm.tsx`, `src/features/series/tests/series-form.test.tsx`, `src/shared/ui/__tests__/tag-input.test.tsx`, `tests/e2e/series.spec.ts`, `DESIGN.md`, `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`.
- **Interfaces:** `TagInput({label,value,onChange,error,errorId})`는 comma-separated string을 consume/emit하고 Enter/comma로 non-empty trimmed chip을 확정하며 remove button으로 한 chip만 삭제한다. Series create는 `keyword`, Audio create/update는 `tags` field명을 그대로 유지한다.
- **범위 밖:** Series 수정·상세 keyword, duplicate/max-count 정책, API schema·endpoint·multipart part 변경, 새 dependency.
- [x] **RED:** Series image-first order와 keyword chip→`"달빛,상담"` request, 공용 input의 blank/remove 동작 실패 test를 작성하고 의도한 실패를 확인했다.
- [x] **GREEN:** `AudioTagInput`을 공용 `TagInput`으로 이름·위치를 정리하고 Audio/Series에서 재사용하며 Series `FileField`를 form 첫 field로 이동했다.
- [x] **REFACTOR:** feature 간 역참조와 중복 chip 구현이 없는지 확인하고 focused·전체 회귀, typecheck·lint·build, Chromium mock E2E와 시각 QA를 실행했다.
- **실행 명령:** `npm run test:run -- src/shared/ui/__tests__/tag-input.test.tsx src/features/series/tests/series-form.test.tsx src/features/audio-contents/tests/audio-form.test.tsx`; `npm run test:run`; `npm run typecheck`; `npm run lint`; `npm run build:prod`; `npm run e2e:mock -- tests/e2e/series.spec.ts --project=chromium`.
- **기대 결과:** 모든 명령 exit 0, Series image-first order와 `keyword="달빛,상담"`, Audio `tags="상담,힐링"`, 빈 chip 0건, 삭제 대상 외 chip 손실 0건, API 계약 변경 0건.
- **수동 확인:** Chromium 1280px Series 생성 화면에서 image가 첫 입력이고 keyword chip 추가·삭제 및 Korean text clipping/overflow가 없는지 확인한다.
**P5-R6 완료 Progress — 2026-08-04:**
- **RED:** shared `TagInput` import 부재, Series image가 title 뒤에 있는 DOM 순서, plain keyword input이 첫 값을 덮어 `"상담"`만 보내는 실패를 확인했다. validation focus 정렬은 image 대신 title이 focus되는 실패로, FileField visible focus는 `focus-within` ring class 부재 실패로 각각 고정했다.
- **GREEN:** `AudioTagInput`을 `src/shared/ui/tag-input.tsx`의 공용 `TagInput`으로 이동·일반화하고 Audio `tags`와 Series `keyword`가 같은 chip UI를 사용하게 했다. Series image를 form 첫 field로 이동하고 request는 `keyword: "달빛,상담"` comma-separated string을 유지했다. image-first validation focus가 보이도록 공용 `FileField` 카드에 기존 ring token의 `focus-within` 상태만 추가했다.
- **REFACTOR/회귀:** focused 3 files / 20 tests와 FileField 포함 focused 3 files / 15 tests를 통과했다. 최종 `npm run test:run`은 82 files / 437 tests, `npm run typecheck`, `npm run lint`, `npm run build:prod`, `git diff --check`는 모두 통과했고 build에는 기존 500kB chunk warning만 남았다. `npm run e2e:mock -- tests/e2e/audio-content.spec.ts tests/e2e/series.spec.ts --project=chromium`은 13 tests passed였다.
- **수동·시각 QA:** Playwright Chromium 768×900·1280×900에서 image-first, Enter/comma chip 2개, remove 후 나머지 값, 빈 draft, 가로 overflow 0건을 확인했다. validation 후 sr-only file input focus와 visible cyan FileField ring을 768×900에서 확인했다. 독립 visual functional/CJK reviewer와 고엄격 diff reviewer의 최종 판정은 모두 PASS/APPROVE, blocker 0건이었다. Mock Preview sticky banner 아래로 static header가 이동한 프레임은 `scrollY=47`의 정상 문서 스크롤로 측정했고 `scrollY=0`에서 동일 focus/ring과 온전한 header를 재확인했다.
---
## Phase 6. Community vertical slice

View File

@@ -285,7 +285,7 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘
| SERIES-011 | 확정 | 장르 목록 endpoint는 검색·페이지 query 없이 활성 장르 전체를 반환한다. 생성 초기 state의 정확한 기본값은 프론트엔드 의존사항이 아니다. |
| SERIES-012 | 확정 | 시리즈 목록 API는 `isActive=true`인 항목만 반환한다. 프론트엔드는 활성 상태 query나 client-side filter를 추가하지 않는다. |
| SERIES-013 | 확정 | 시리즈 soft delete 성공 시 선택 캐릭터의 시리즈 목록으로 이동해 재조회하고 성공 알림을 표시한다. 현재 상세 화면에 머물지 않으며 서버의 active-only 목록에서 비활성 항목이 제외돼야 한다. |
| SERIES-014 | 확정 | 생성 multipart의 `image``request`는 필수다. 생성 request는 `keyword` 단일 문자열을 사용하`keywords` 배열을 보내지 않는다. |
| SERIES-014 | 확정 | 생성 multipart의 `image``request`는 필수다. 생성 화면은 image 입력을 폼 최상단에 두고 keyword를 Enter/comma로 확정·삭제하는 chip UI로 제공한다. 생성 request는 chip 값을 comma-separated `keyword` 단일 문자열로 보내`keywords` 배열을 보내지 않는다. |
| SERIES-015 | 확정 | 목록과 상세는 동일한 `SeriesListItem` schema를 사용하고 `genreId`, enum 배열 `publishedDaysOfWeek`, enum `state`를 반환한다. 화면 label은 클라이언트에서 표시용으로 변환하되 원본 enum과 ID를 수정 payload에 사용한다. |
| SERIES-016 | 확정 | 연결 후보는 `GET .../contents/search?search_word=...`, 연결은 `{ "contentIdList": [...] }`, 해제는 body 없는 DELETE를 사용한다. |
| SERIES-017 | 확정 | 시리즈 상세 응답은 목록 item과 동일한 수정용 원본값 `genreId`, enum `publishedDaysOfWeek`, enum `state`를 제공하므로 별도 edit DTO가 필요 없다. 수정 화면은 상세 응답으로 기존 선택값을 초기화하고, 장르 API는 option 목록 표시용으로 호출한다. |
@@ -937,3 +937,4 @@ vertical slice와 남은 수동 QA에서 검증한다. `EXT-006`은 현재 확
| 2026-07-31 | 사용자 직접 지시에 따라 로컬 자동 Gate와 지원 browser 범위는 데스크톱 Chrome·모바일 Chrome의 Chrome 2종으로 한정한다. Playwright는 Chromium/mobile Chrome project만 유지하고, 현재 제품 지원 대상이 아닌 WebKit·Mobile Safari 자동 실행과 수동 QA는 테스트 시간을 크게 늘리므로 현재 릴리스 범위에서 제외한다. |
| 2026-08-03 | 공통 `AdminAudioPlayer`의 native media 동작과 단일 재생·오류 계약은 유지하고, Plyr·Media Chrome의 audio-only control 배치를 참고한 compact 가로형 UI로 개선한다. 플레이어 내부 image·video 영역과 새 외부 라이브러리·waveform은 추가하지 않으며 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet에 동일하게 적용한다. |
| 2026-08-04 | Audio form은 tags를 chip으로 입력하고 Enter/comma 추가와 remove button 삭제를 제공하되 payload는 comma-separated string으로 유지한다. 가격은 숫자만 표시·저장하는 `0..99999` 정수이며 0은 무료다. 가격이 0보다 클 때만 purchase option, preview 생성·시간, point 사용 가능 여부를 보이고, 0 전환 시 `purchaseOption=BOTH`, `isGeneratePreview=false`, `isPointAvailable=false`, preview times=`null`로 초기화한다. `limited`는 문서화되지 않은 NullableInt32이므로 UI를 제거하고 `null`을 보내며, `languageCode` UI는 제거하고 `null`, 독립 `isOnlyRental` UI는 제거하고 `false`를 보낸다. preview 시작·종료는 오디오 duration offset으로 text 입력하며 완전한 `HH:MM:SS`만 안내하고 request에는 입력한 `HH:mm:ss` 값을 그대로 보낸다. 예약 일시는 예약 공개에서만 native `datetime-local`로 렌더링하고 custom `showPicker()`는 사용하지 않는다. native radio/checkbox는 하나의 선택 카드 시각 문법을 사용한다. |
| 2026-08-04 | Series 생성 form은 image 입력을 최상단에 배치하고 keyword를 Audio tags와 같은 공용 `TagInput` chip UI로 입력한다. Enter/comma로 확정하고 remove button으로 삭제하며 server request는 기존 comma-separated `keyword` 문자열 계약을 유지한다. |

View File

@@ -8,7 +8,6 @@ import type { UploadAudioContentOptions, UploadAuthDependencies } from "@/featur
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, formatPrice, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers";
import type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers";
import { AudioContentCreateOptions } from "@/features/audio-contents/components/AudioContentCreateOptions";
import { AudioTagInput } from "@/features/audio-contents/components/AudioTagInput";
import { AudioContentThemeSelect } from "@/features/audio-contents/components/AudioContentThemeSelect";
import { ReleaseScheduleField } from "@/features/audio-contents/components/ReleaseScheduleField";
import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
@@ -25,6 +24,7 @@ import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
import { FileField } from "@/shared/ui/file-field";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
import { TagInput } from "@/shared/ui/tag-input";
import { CAN_PRICE_MAX } from "@/shared/validation/can-price";
import { AUDIO_FILE_POLICY } from "@/shared/validation/audio-file-policy";
import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
@@ -234,7 +234,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
{errors.title === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.title} role="alert">{errors.title}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"> <textarea aria-describedby={errors.detail === undefined ? undefined : errorIds.detail} aria-invalid={errors.detail === undefined ? undefined : true} className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setDetail(event.currentTarget.value)} value={detail} /></label>
{errors.detail === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.detail} role="alert">{errors.detail}</p>}
<AudioTagInput error={errors.tags} errorId={errorIds.tags} onChange={setTags} value={tags} />
<TagInput error={errors.tags} errorId={errorIds.tags} label="태그" onChange={setTags} value={tags} />
{mode === "create" ? <AudioContentThemeSelect error={errors.theme} errorId={errorIds.theme} onChange={setThemeId} themes={themes} value={themeId} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">: {audio?.themeStr ?? "-"} ( )</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.price === undefined ? undefined : errorIds.price} aria-invalid={errors.price === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" inputMode="numeric" min="0" onChange={(event) => changePrice(event.currentTarget.value)} step="1" type="number" value={price} /></label><p className="text-sm text-muted-foreground">단위: </p>
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}

View File

@@ -16,6 +16,7 @@ import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
import { FileField } from "@/shared/ui/file-field";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
import { TagInput } from "@/shared/ui/tag-input";
import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
type FieldErrors = {
@@ -221,8 +222,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
{errors.title === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.title} role="alert">{errors.title}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><textarea aria-describedby={errors.introduction === undefined ? undefined : errorIds.introduction} aria-invalid={errors.introduction === undefined ? undefined : true} className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setIntroduction(event.currentTarget.value)} value={introduction} /></label>
{errors.introduction === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.introduction} role="alert">{errors.introduction}</p>}
{mode === "create" ? <label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.keyword === undefined ? undefined : errorIds.keyword} aria-invalid={errors.keyword === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setKeyword(event.currentTarget.value)} value={keyword} /></label> : null}
{errors.keyword === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.keyword} role="alert">{errors.keyword}</p>}
{mode === "create" ? <TagInput error={errors.keyword} errorId={errorIds.keyword} label="키워드" onChange={setKeyword} value={keyword} /> : null}
<GenreCombobox error={errors.genre} errorId={errorIds.genre} genres={genres} onChange={setGenreId} value={genreId} />
<PublishedDaysField error={errors.days} errorId={errorIds.days} onChange={setPublishedDays} value={publishedDays} />
{mode === "edit" ? <label className="flex flex-col gap-2 text-sm font-semibold"><select aria-label="상태" className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setState(event.currentTarget.value as SeriesState)} value={state}>{stateOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label> : null}

View File

@@ -135,6 +135,9 @@ test("SeriesForm creates with required image request and navigates to list notif
fireEvent.change(screen.getByLabelText("제목"), { target: { value: "새 시리즈" } });
fireEvent.change(screen.getByLabelText("소개"), { target: { value: "새 시리즈 소개" } });
fireEvent.change(screen.getByLabelText("키워드"), { target: { value: "달빛" } });
fireEvent.keyDown(screen.getByLabelText("키워드"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("키워드"), { target: { value: "상담" } });
fireEvent.keyDown(screen.getByLabelText("키워드"), { key: "," });
fireEvent.click(screen.getByRole("checkbox", { name: "월요일" }));
fireEvent.change(screen.getByLabelText("장르"), { target: { value: "77" } });
fireEvent.change(screen.getByLabelText("작가"), { target: { value: "작가" } });
@@ -149,7 +152,7 @@ test("SeriesForm creates with required image request and navigates to list notif
await waitFor(() => expect(requests.some((request) => request.method === "POST")).toBe(true));
const requestBody = requests.find((request) => request.method === "POST")?.body as FormData;
expect(requestBody.get("image")).toBeInstanceOf(File);
await expect((requestBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "새 시리즈", introduction: "새 시리즈 소개", publishedDaysOfWeek: ["MON"], keyword: "달빛", genreId: 77, isAdult: false, writer: "작가", studio: "스튜디오" }));
await expect((requestBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "새 시리즈", introduction: "새 시리즈 소개", publishedDaysOfWeek: ["MON"], keyword: "달빛,상담", genreId: 77, isAdult: false, writer: "작가", studio: "스튜디오" }));
expect(window.location.pathname).toBe("/ai-characters/101/series");
expect(window.history.state.successNotification).toBe("시리즈를 생성했습니다.");
});
@@ -292,7 +295,7 @@ test("SeriesForm links validation errors and focuses the first invalid control",
expect(daysError).toHaveAttribute("id", "series-days-error");
expect(titleInput).toHaveAttribute("aria-describedby", "series-title-error");
expect(screen.getByLabelText("소개")).toHaveAttribute("aria-describedby", "series-introduction-error");
expect(screen.getByLabelText("키워드")).toHaveAttribute("aria-describedby", "series-keyword-error");
expect(screen.getByLabelText("키워드").getAttribute("aria-describedby")?.split(" ")).toContain("series-keyword-error");
expect(screen.getByLabelText("장르")).toHaveAttribute("aria-describedby", "series-genre-error");
expect(screen.getByRole("checkbox", { name: "월요일" })).toHaveAttribute("aria-describedby", "series-days-error");
expect(titleInput).toHaveAttribute("aria-invalid", "true");

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

@@ -1,13 +1,14 @@
import { useId, useState } from "react";
type AudioTagInputProps = {
export type TagInputProps = {
readonly error: string | undefined;
readonly errorId: string;
readonly label: string;
readonly onChange: (value: string) => void;
readonly value: string;
};
export function AudioTagInput({ error, errorId, onChange, value }: AudioTagInputProps) {
export function TagInput({ error, errorId, label, onChange, value }: TagInputProps) {
const [draft, setDraft] = useState("");
const inputId = useId();
const helpId = `${inputId}-help`;
@@ -37,17 +38,21 @@ export function AudioTagInput({ error, errorId, onChange, value }: AudioTagInput
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold" htmlFor={inputId}></label>
<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={`태그 ${tag} 삭제`} className="min-h-11 rounded-md px-3 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"></button>
<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로 .</p>
<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>
);

View File

@@ -143,7 +143,13 @@ test("desktop Series CRUD uses genre lookup, crop, edit initialization, and soft
// When
await page.getByLabel("제목").fill("새 시리즈");
await page.getByLabel("소개").fill("새로운 시리즈 소개");
await page.getByLabel("키워드").fill("달빛");
const keywordInput = page.getByRole("textbox", { name: "키워드" });
await keywordInput.fill("달빛");
await keywordInput.press("Enter");
await keywordInput.fill("상담");
await keywordInput.press(",");
await expect(page.getByRole("button", { name: "키워드 달빛 삭제" })).toBeVisible();
await expect(page.getByRole("button", { name: "키워드 상담 삭제" })).toBeVisible();
await page.getByRole("checkbox", { name: "월요일" }).check();
await page.getByLabel("장르").selectOption("77");
await setSeriesImageFile(page);