From cc1f807ad62a63e3a6f138d480b57d31a3d1dc1d Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 1 Sep 2026 01:15:26 +0900 Subject: [PATCH 1/9] =?UTF-8?q?fix=20:=20=EC=9E=90=EB=8F=99=EC=99=84?= =?UTF-8?q?=EC=84=B1=20=EC=B4=88=EC=84=B1=20=EA=B2=80=EC=83=89=EC=9D=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=A4=91=EA=B0=84=20=EA=B3=B5=EB=B0=B1?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=81=8A=EA=B8=B0=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95=20(#320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getChoseong은 공백을 그대로 남긴다 — getChoseong('요네즈 켄시')는 'ㅇㄴㅈ ㅋㅅ'다. 사람은 초성을 붙여서 치므로("ㅇㄴㅈㅋㅅ") startsWith 비교가 공백에서 실패했다. 전사된 일본 아티스트명은 대부분 "성 이름" 꼴이라 초성 검색이 사실상 첫 단어까지만 동작했다. 양쪽에서 공백을 지우고 비교한다. 라벨의 소문자형과 초성형은 후보를 만들 때 미리 계산해둔다. 입력할 때마다 162개 라벨에 getChoseong을 다시 돌릴 이유가 없다. 함께, 공백만 입력했을 때 무관한 후보 10개가 뜨던 것을 막는다. 빈 문자열은 모든 후보의 접두사라 필터를 그대로 통과했다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3Qg1DFPBGjtDjhBN5CuAn --- apps/web/src/utils/getArtistAlias.ts | 41 ++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/apps/web/src/utils/getArtistAlias.ts b/apps/web/src/utils/getArtistAlias.ts index 21eeb568..5c1bcf6f 100644 --- a/apps/web/src/utils/getArtistAlias.ts +++ b/apps/web/src/utils/getArtistAlias.ts @@ -4,17 +4,37 @@ import { artistAlias } from '@repo/constants'; export type SearchCandidate = { label: string; value: string }; -const createCandidateList = (): SearchCandidate[] => { - const list: SearchCandidate[] = []; +// getChoseong은 공백을 그대로 남긴다 — getChoseong('요네즈 켄시') === 'ㅇㄴㅈ ㅋㅅ'. +// 사람은 초성을 붙여서 치므로("ㅇㄴㅈㅋㅅ") 양쪽에서 공백을 지우고 비교해야 +// 이름 중간의 공백을 넘어가도 초성 검색이 이어진다. 전사된 일본 아티스트명은 +// 대부분 "성 이름" 꼴이라 이걸 안 하면 초성 검색이 첫 단어에서 끊긴다. +const removeSpaces = (value: string) => value.replace(/\s+/g, ''); + +type IndexedCandidate = SearchCandidate & { + /** 소문자로 맞춘 라벨 — 입력할 때마다 다시 만들지 않도록 미리 계산한다 */ + searchLabel: string; + /** 공백을 지운 초성. 한글이 없는 라벨(YOASOBI, 米津玄師)은 빈 문자열이 된다 */ + choseong: string; +}; + +const createCandidateList = (): IndexedCandidate[] => { + const list: IndexedCandidate[] = []; + + const pushCandidate = (label: string, value: string) => { + list.push({ + label, + value, + searchLabel: label.toLowerCase(), + choseong: removeSpaces(getChoseong(label)), + }); + }; Object.entries(artistAlias).forEach(([officialName, aliases]) => { // 공식 명칭 검색 후보에 추가 - list.push({ label: officialName, value: officialName }); + pushCandidate(officialName, officialName); // 별명들 검색 후보에 추가 - aliases.forEach(alias => { - list.push({ label: alias, value: officialName }); - }); + aliases.forEach(alias => pushCandidate(alias, officialName)); }); return list; @@ -27,13 +47,18 @@ export const getAutoCompleteSuggestions = (query: string): SearchCandidate[] => if (!query) return []; const normalizedQuery = query.toLowerCase().trim(); // 대소문자 무시 + // 공백만 입력한 경우 — 빈 문자열은 모든 후보의 접두사라 후보 10개가 그대로 뜬다 + if (!normalizedQuery) return []; + + const choseongQuery = removeSpaces(normalizedQuery); // 배열 필터링 (여기가 핵심) // includes: 중간에 포함된 것도 찾음 ("라시" -> "아라시") // startsWith: 앞에서부터 일치하는 것만 찾음 ("아" -> "아라시") -> 보통 자동완성은 이걸 씀 return SEARCH_CANDIDATES.filter( candidate => - candidate.label.toLowerCase().startsWith(normalizedQuery) || - getChoseong(candidate.label).startsWith(normalizedQuery), + candidate.searchLabel.startsWith(normalizedQuery) || + // 한글이 없는 라벨은 초성이 빈 문자열이라 아무 입력에나 걸리지 않도록 걸러낸다 + (candidate.choseong !== '' && candidate.choseong.startsWith(choseongQuery)), ).slice(0, 10); // 성능을 위해 상위 10개만 자름 }; From 56a82b3fd3d48e8df13105b906d57e25923f53dd Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 1 Sep 2026 01:16:04 +0900 Subject: [PATCH 2/9] =?UTF-8?q?fix=20:=20=EC=9E=90=EB=8F=99=EC=99=84?= =?UTF-8?q?=EC=84=B1=20=EB=B3=84=EC=B9=AD=20=EC=B9=98=ED=99=98=EC=9D=84=20?= =?UTF-8?q?=EC=A0=95=ED=99=95=20=EC=9D=BC=EC=B9=98=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 치환 조건이 "후보가 하나일 때"라, 같은 아티스트의 별칭끼리 접두가 겹치면 치환이 통째로 건너뛰어졌다. "원오크"는 "원오크락"과 함께 걸려 후보가 2개가 되는데, ONE OK ROCK은 라틴 표기라 artist_ko가 비어 있어 치환 없이는 검색 결과가 0건이었다. "라르크"(→"라르크 앙 시엘")도 같다. 개수를 세지 말고 입력과 정확히 일치하는 라벨을 찾는다. 대소문자도 무시해 "eve"가 공식 명칭 "Eve"로 치환된다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3Qg1DFPBGjtDjhBN5CuAn --- apps/web/src/hooks/useSearchSong.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index 898210b0..9aaac0c3 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -63,14 +63,14 @@ export default function useSearchSong() { return; } - let parsedSearch = trimSearch; - - if (autoCompleteList.length === 1) { - if (autoCompleteList[0].label === trimSearch) { - // 자동완성 리스트가 하나(정확히 일치하면)고 label도 일치하면 해당 alias의 value로 자동 치환 - parsedSearch = autoCompleteList[0].value; - } - } + // 입력한 말이 별칭과 정확히 겹치면 그 별칭의 공식 명칭으로 바꿔 검색한다. + // 후보 개수로 판단하면("리스트가 하나일 때만") 같은 아티스트의 별칭끼리 접두가 + // 겹치는 순간 치환이 통째로 건너뛰어졌다 — "원오크"는 "원오크락"과 함께 걸려 + // 후보가 2개가 되는데, ONE OK ROCK은 artist_ko가 비어 있어 치환 없이는 0건이었다. + const exactMatch = autoCompleteList.find( + candidate => candidate.label.toLowerCase() === trimSearch.toLowerCase(), + ); + const parsedSearch = exactMatch ? exactMatch.value : trimSearch; // 중간 띄어쓰기는 제거하지 않고 그대로 전달한다. // 검색어의 공백 처리(토큰 분리 → %로 치환)는 검색 API(/api/search)가 담당한다. From 8bce9fd1e640a669dce763660f46c5aee132a9a1 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 1 Sep 2026 01:16:40 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor=20:=20=EC=9E=90=EB=8F=99=EC=99=84?= =?UTF-8?q?=EC=84=B1=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20search=EC=97=90=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=97=B0=EB=8F=99=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EC=A0=9C=EA=B1=B0=20(#320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useDeferredValue를 걷어낸다. 원래 용도는 "입력은 즉시 반응해야 하는데 그 값으로 그리는 목록이 무거울 때"인데, 여기서는 두 전제가 다 어긋난다. 사전 규모가 수백 개 문자열 비교라 미룰 만큼 무겁지 않고, 무엇보다 handleSearch가 이 목록을 읽어 별칭 치환을 판정한다. 목록이 한 박자 늦으면 "검색할 문자열"과 "그 문자열의 후보"가 어긋나, 붙여넣기 직후 엔터나 IME 확정 직후 엔터처럼 지연이 큰 순간에 치환이 조용히 빠진다. 평소에는 늦은 값의 후보가 상위집합이라 우연히 맞아, 재현이 어려운 형태로만 드러난다. 드롭다운이 입력보다 한 렌더 늦게 갱신되던 것도 함께 사라진다. handleSearch의 termOverride·typeOverride는 부르는 곳이 없다(엔터 키와 검색 버튼 두 곳뿐이고 둘 다 인자가 없다). 시그니처만 보면 외부에서 검색어를 주입할 수 있는 것처럼 읽히지만, 실제로 그렇게 부르면 후보 목록은 여전히 입력창의 search 기준이라 치환이 엉뚱하게 동작한다. 필요해지면 후보 계산까지 함께 설계하는 게 맞다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3Qg1DFPBGjtDjhBN5CuAn --- apps/web/src/hooks/useSearchSong.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index 9aaac0c3..6abda24d 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -1,4 +1,4 @@ -import { useCallback, useDeferredValue, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { usePostSearchLogMutation } from '@/queries/searchLogQuery'; @@ -47,16 +47,15 @@ export default function useSearchSong() { const { addToHistory } = useSearchHistoryStore(); const { addGuestToSingSong, removeGuestToSingSong } = useGuestToSingStore(); - const deferredSearch = useDeferredValue(search); + // handleSearch가 이 목록에서 별칭 치환을 하므로 search를 그대로 따라가야 한다. + // useDeferredValue를 끼우면 목록이 한 박자 늦어, 붙여넣기 직후 엔터처럼 지연이 큰 + // 순간에 "검색할 문자열"과 "그 문자열의 후보"가 어긋나 치환이 조용히 빠진다. + // 사전 규모가 수백 개라 미룰 만큼 무겁지도 않다. + const autoCompleteList = useMemo(() => getAutoCompleteSuggestions(search), [search]); - const autoCompleteList = useMemo( - () => getAutoCompleteSuggestions(deferredSearch), - [deferredSearch], - ); - - const handleSearch = (termOverride?: string, typeOverride?: SearchType) => { + const handleSearch = () => { // trim 제거 - const trimSearch = (termOverride ?? search).trim(); + const trimSearch = search.trim(); if (!trimSearch) { setQuery(''); @@ -77,7 +76,7 @@ export default function useSearchSong() { if (parsedSearch) { setQuery(parsedSearch); setSearch(parsedSearch); - setQueryType(typeOverride ?? searchType); + setQueryType(searchType); addToHistory(parsedSearch); postSearchLog(parsedSearch); } From e82bf92cb0c50e1250eb36757f63e692946df29f Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 1 Sep 2026 14:52:10 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix=20:=20artistAlias=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B3=A1=20=EC=A0=9C=EB=AA=A9=20=EB=B3=84=EC=B9=AD=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 곡 제목이 별칭으로 등록돼 있으면 자동완성이 곡 검색을 아티스트 전곡 검색으로 바꿔버린다. 우세와·신시대(Ado), 나이트댄서(imase), 만찬가(tuki.), 프리텐더(Official髭男dism)를 제거했다. 배치 영향 없음 — translationJpn·backfillArtists는 aliases[0]만 대표 한국어 표기로 읽는데 제거 대상은 전부 인덱스 1 이후다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U4AQGWz3KGURiz6RZ5knEE --- packages/constants/src/artistAlias.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/constants/src/artistAlias.ts b/packages/constants/src/artistAlias.ts index 00c48382..59aaa963 100644 --- a/packages/constants/src/artistAlias.ts +++ b/packages/constants/src/artistAlias.ts @@ -16,11 +16,11 @@ export const artistAlias = { "Creepy Nuts": ["크리피 너츠"], // [Gemini 추천 아티스트 추가] 한국 인기 최상위 - Ado: ["아도", "우세와", "신시대"], - imase: ["이마세", "나이트댄서"], + Ado: ["아도"], + imase: ["이마세"], "ONE OK ROCK": ["원오크락", "원오크", "원옥"], "X JAPAN": ["엑스재팬", "엑스제팬"], - "tuki.": ["츠키", "투키", "만찬가"], + "tuki.": ["츠키", "투키"], HoneyWorks: ["허니웍스"], "L'Arc~en~Ciel": ["라르크 앙 시엘", "라르크"], 松田聖子: ["마츠다 세이코"], @@ -36,7 +36,7 @@ export const artistAlias = { 浜崎あゆみ: ["하마사키 아유미"], 水樹奈々: ["미즈키 나나"], "モーニング娘。": ["모닝구 무스메"], - Official髭男dism: ["오피셜히게단디즘", "히게단", "프리텐더"], + Official髭男dism: ["오피셜히게단디즘", "히게단"], ヨルシカ: ["요루시카"], 中島美嘉: ["나카시마 미카"], 宇多田ヒカル: ["우타다 히카루"], From 49ec488bce9cba454901f726e7858bb2e98afd5a Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 1 Sep 2026 14:52:14 +0900 Subject: [PATCH 5/9] =?UTF-8?q?chore=20:=20sitemap=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(#320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U4AQGWz3KGURiz6RZ5knEE --- apps/web/public/sitemap-0.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml index 9a8cc028..b9073835 100644 --- a/apps/web/public/sitemap-0.xml +++ b/apps/web/public/sitemap-0.xml @@ -1,6 +1,6 @@ -https://www.singcode.kr/manifest.webmanifest2026-08-28T16:24:56.791Zweekly0.7 -https://www.singcode.kr/patch-notes2026-08-28T16:24:56.793Zweekly0.7 -https://www.singcode.kr2026-08-28T16:24:56.794Zweekly0.7 +https://www.singcode.kr/manifest.webmanifest2026-09-01T05:51:14.087Zweekly0.7 +https://www.singcode.kr2026-09-01T05:51:14.088Zweekly0.7 +https://www.singcode.kr/patch-notes2026-09-01T05:51:14.088Zweekly0.7 \ No newline at end of file From 986d45ab2d0cd13deed63d5c8a89d3672228e4d7 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 8 Sep 2026 20:17:47 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix=20:=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EA=B0=80=20=EC=9E=88=EB=8A=94=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=96=B4=EB=A7=8C=20=EC=9D=B8=EA=B8=B0=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=96=B4=EB=A1=9C=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 결과 0건인 오타·존재하지 않는 곡까지 집계되어 인기 검색어가 오염되던 것을 막는다. 검색 실행 시점에는 후보만 잡아두고, 결과가 도착한 뒤 1건 이상일 때만 로그를 남긴다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013T8GYG7XyLmTs1SFKMwsaA --- apps/web/src/hooks/useSearchSong.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index 6abda24d..161be35b 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { usePostSearchLogMutation } from '@/queries/searchLogQuery'; @@ -43,6 +43,10 @@ export default function useSearchSong() { const { mutate: postSearchLog } = usePostSearchLogMutation(); + // 검색 결과 도착을 기다리는 인기 검색어 로그 후보 + const [pendingLog, setPendingLog] = useState<{ text: string; seq: number } | null>(null); + const seqRef = useRef(0); + const { setFooterAnimateKey } = useFooterAnimateStore(); const { addToHistory } = useSearchHistoryStore(); const { addGuestToSingSong, removeGuestToSingSong } = useGuestToSingStore(); @@ -78,10 +82,30 @@ export default function useSearchSong() { setSearch(parsedSearch); setQueryType(searchType); addToHistory(parsedSearch); - postSearchLog(parsedSearch); + // 인기 검색어 로그는 여기서 바로 남기지 않는다. + // 결과가 0건인 오타·존재하지 않는 곡까지 집계되면 인기 검색어가 오염된다. + // 검색 결과가 도착한 뒤 1건이라도 있을 때만 아래 effect가 기록한다. + // seq는 같은 검색어를 연달아 검색해도 effect가 다시 돌게 하는 용도다. + seqRef.current += 1; + setPendingLog({ text: parsedSearch, seq: seqRef.current }); } }; + // 검색 결과가 1건 이상일 때만 인기 검색어 로그를 남긴다. + useEffect(() => { + if (!pendingLog) return; + // 이번 검색어의 결과가 아직 도착하지 않았으면 대기한다. + if (query !== pendingLog.text || isPendingSearch) return; + + setPendingLog(null); + if (isError) return; + + const hasResult = searchResults?.pages.some(page => page.data.length > 0) ?? false; + if (hasResult) { + postSearchLog(pendingLog.text); + } + }, [pendingLog, query, isPendingSearch, isError, searchResults, postSearchLog]); + const handleSearchTypeChange = (value: SearchType) => { setSearchType(value); }; From 1d48530999fe3e47b30c074733cda0f1caf4fd25 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 8 Sep 2026 20:18:19 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix=20:=20=EC=A0=9C=EB=AA=A9=C2=B7=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=ED=83=AD=EC=97=90=EC=84=9C=20=EC=95=84=ED=8B=B0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=C2=B7?= =?UTF-8?q?=EB=B3=84=EC=B9=AD=20=EC=B9=98=ED=99=98=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?(#321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제목 탭에서도 아티스트 후보가 떠서, "원오크"를 고르면 ONE OK ROCK으로 치환된 채 제목 검색이 나가 결과가 0건이었다. 드롭다운만 숨기면 절반만 막힌다. 치환은 handleSearch가 후보 목록을 직접 보고 하므로, 후보를 직접 타이핑해 엔터를 누르는 경로가 그대로 남는다. 후보 목록 자체를 비워 두 경로를 한 곳에서 함께 막는다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013T8GYG7XyLmTs1SFKMwsaA --- apps/web/src/app/search/HomePage.tsx | 3 ++- apps/web/src/hooks/useSearchSong.ts | 11 ++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index a6abae73..63d01c05 100644 --- a/apps/web/src/app/search/HomePage.tsx +++ b/apps/web/src/app/search/HomePage.tsx @@ -308,7 +308,8 @@ export default function SearchPage() { onFocus={() => setIsFocusAuto(true)} onBlur={() => setIsFocusAuto(false)} /> - {isFocusAuto && searchType !== 'number' && ( + {/* 검색 타입별 노출 여부는 useSearchSong이 autoCompleteList를 비우는 것으로 정한다 */} + {isFocusAuto && ( getAutoCompleteSuggestions(search), [search]); + const autoCompleteList = useMemo( + () => (canUseArtistAlias ? getAutoCompleteSuggestions(search) : []), + [search, canUseArtistAlias], + ); const handleSearch = () => { // trim 제거 From ac07aa211d056c8f47b03668723fef5be4be7125 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 8 Sep 2026 20:18:52 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat=20:=20=EC=9E=90=EB=8F=99=EC=99=84?= =?UTF-8?q?=EC=84=B1=20=ED=82=A4=EB=B3=B4=EB=93=9C=20=EB=82=B4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98=C2=B7ARIA=20=EC=B6=94=EA=B0=80=20(#?= =?UTF-8?q?322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ↑/↓로 후보를 이동하고 엔터로 고를 수 있게 한다. 입력창에 combobox ARIA를, 목록에 listbox/option을 붙여 스크린리더가 활성 후보를 읽을 수 있게 했다. - 내비게이션 로직은 useAutocompleteNavigation 훅으로 분리했다. SearchAutocomplete을 이달의 아티스트 투표 화면도 쓰고 있어, 한쪽만 고치면 거기는 여전히 마우스로만 아티스트를 담을 수 있다. - ↑/↓는 keydown, 엔터는 기존대로 keyup에서 처리한다. 엔터를 keydown으로 옮기면 한글 조합 확정 엔터의 동작이 브라우저마다 달라진다. 방향키에는 isComposing 가드를 뒀다. - listbox 안의 button을 li[role=option]으로 바꿨다. 포커스가 입력창을 떠나면 aria-activedescendant 방식이 깨진다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013T8GYG7XyLmTs1SFKMwsaA --- apps/web/src/app/popular/ArtistVotePanel.tsx | 52 ++++++++--- apps/web/src/app/search/HomePage.tsx | 27 +++++- .../web/src/app/search/SearchAutocomplete.tsx | 37 +++++--- .../src/hooks/useAutocompleteNavigation.ts | 87 +++++++++++++++++++ apps/web/src/hooks/useSearchSong.ts | 6 +- 5 files changed, 184 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/hooks/useAutocompleteNavigation.ts diff --git a/apps/web/src/app/popular/ArtistVotePanel.tsx b/apps/web/src/app/popular/ArtistVotePanel.tsx index c8fb99b4..efc66fc8 100644 --- a/apps/web/src/app/popular/ArtistVotePanel.tsx +++ b/apps/web/src/app/popular/ArtistVotePanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import SearchAutocomplete from '@/app/search/SearchAutocomplete'; import { Button } from '@/components/ui/button'; @@ -14,6 +14,7 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; +import useAutocompleteNavigation from '@/hooks/useAutocompleteNavigation'; import { useMyArtistVotesQuery, useSaveArtistVotesMutation } from '@/queries/artistVoteQuery'; import { useArtistSearchQuery } from '@/queries/artistsQuery'; import { useUserQuery } from '@/queries/userQuery'; @@ -25,6 +26,8 @@ import ArtistVoteRow from './ArtistVoteRow'; const STEP = 10; +const ARTIST_VOTE_LISTBOX_ID = 'artist-vote-autocomplete-listbox'; + /** * 이번 달(아직 확정되지 않은 달) 화면. 랭킹 대신 내가 이번 달에 투표한 아티스트를 편집한다. * 검색으로 고른 아티스트는 0P로 목록에 담기기만 하고, 값을 고친 뒤 엔터(또는 저장 버튼)로 @@ -50,14 +53,20 @@ export default function ArtistVotePanel() { const { mutate: saveVotes, isPending } = useSaveArtistVotesMutation(); const { data: searchResults = [], isFetching: isSearching } = useArtistSearchQuery(query); - const autoCompleteList = searchResults.map(artist => ({ - // 한국어 표기가 원어 표기와 같으면(예: 'IVE') 같은 글자를 두 번 보여줄 뿐이라 생략한다. - label: - artist.name_ko && artist.name_ko !== artist.name - ? `${artist.name} (${artist.name_ko})` - : artist.name, - value: artist.name, - })); + // useMemo 로 참조를 고정한다. 매 렌더 새 배열을 만들면 + // useAutocompleteNavigation 이 목록이 바뀐 것으로 보고 활성 후보를 계속 지운다. + const autoCompleteList = useMemo( + () => + searchResults.map(artist => ({ + // 한국어 표기가 원어 표기와 같으면(예: 'IVE') 같은 글자를 두 번 보여줄 뿐이라 생략한다. + label: + artist.name_ko && artist.name_ko !== artist.name + ? `${artist.name} (${artist.name_ko})` + : artist.name, + value: artist.name, + })), + [searchResults], + ); // 자동완성은 결과가 없으면 아무것도 그리지 않아 검색이 동작하는지조차 알기 어렵다. // 검색어를 넣었는데 후보가 없으면 그 사실을 그대로 알려준다. const isSearchEmpty = query.trim().length > 0 && !isSearching && autoCompleteList.length === 0; @@ -88,6 +97,16 @@ export default function ArtistVotePanel() { setIsFocusAuto(true); }; + const isAutocompleteOpen = isFocusAuto && autoCompleteList.length > 0; + + const { activeCandidate, handleKeyDown, inputAriaProps, listboxProps } = + useAutocompleteNavigation({ + listboxId: ARTIST_VOTE_LISTBOX_ID, + candidates: autoCompleteList, + isOpen: isAutocompleteOpen, + onClose: () => setIsFocusAuto(false), + }); + const handleSelectArtist = (name: string) => { setQuery(''); setIsFocusAuto(false); @@ -98,6 +117,12 @@ export default function ArtistVotePanel() { setAddedArtists(prev => [...prev, selected ?? { name, name_ko: null }]); }; + // 엔터는 키보드로 고른 후보를 목록에 담는다. 고른 후보가 없으면 아무 일도 하지 않는다. + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key !== 'Enter' || !activeCandidate) return; + handleSelectArtist(activeCandidate.value); + }; + const handleChange = (artist: string, amount: number) => { setDraft(prev => ({ ...prev, [artist]: Math.max(0, amount) })); }; @@ -157,11 +182,18 @@ export default function ArtistVotePanel() { placeholder="아티스트 검색" value={query} onChange={event => handleChangeQuery(event.target.value)} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} onFocus={() => setIsFocusAuto(true)} onBlur={() => setIsFocusAuto(false)} + {...inputAriaProps} /> {isFocusAuto && ( - + )} diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index 63d01c05..14d59846 100644 --- a/apps/web/src/app/search/HomePage.tsx +++ b/apps/web/src/app/search/HomePage.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { TOUR_DEMO_SEARCH_TERM, TOUR_DEMO_SONG } from '@/constants/tourDemoSong'; +import useAutocompleteNavigation from '@/hooks/useAutocompleteNavigation'; import useSaveSongModal from '@/hooks/useSaveSongModal'; import useSearchSong from '@/hooks/useSearchSong'; import useSearchTourController from '@/hooks/useSearchTourController'; @@ -26,6 +27,8 @@ import SearchResultCard from './SearchResultCard'; import SearchStatus from './SearchStatus'; import SearchTour from './SearchTour'; +const AUTOCOMPLETE_LISTBOX_ID = 'search-autocomplete-listbox'; + export default function SearchPage() { const { search, @@ -114,12 +117,27 @@ export default function SearchPage() { const isNumberKeypadVisible = searchType === 'number' && (isNumberKeypadOpen || searchSongs.length === 0); - // 엔터 키 처리 + const isAutocompleteOpen = isFocusAuto && autoCompleteList.length > 0; + + const { activeCandidate, handleKeyDown, inputAriaProps, listboxProps } = + useAutocompleteNavigation({ + listboxId: AUTOCOMPLETE_LISTBOX_ID, + candidates: autoCompleteList, + isOpen: isAutocompleteOpen, + onClose: () => setIsFocusAuto(false), + }); + + // 엔터 키 처리 — 활성 후보가 있으면 그 후보로, 없으면 입력값 그대로 검색한다. const handleKeyUp = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { + if (e.key !== 'Enter') return; + + if (activeCandidate) { + setSearch(activeCandidate.value); + handleSearch(activeCandidate.value); + } else { handleSearch(); - setIsFocusAuto(false); } + setIsFocusAuto(false); }; const handleSearchClick = () => { @@ -304,15 +322,18 @@ export default function SearchPage() { className="pl-8" value={search} onChange={handleChangeSearch} + onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onFocus={() => setIsFocusAuto(true)} onBlur={() => setIsFocusAuto(false)} + {...inputAriaProps} /> {/* 검색 타입별 노출 여부는 useSearchSong이 autoCompleteList를 비우는 것으로 정한다 */} {isFocusAuto && ( )} diff --git a/apps/web/src/app/search/SearchAutocomplete.tsx b/apps/web/src/app/search/SearchAutocomplete.tsx index 7d3bfbae..5a843754 100644 --- a/apps/web/src/app/search/SearchAutocomplete.tsx +++ b/apps/web/src/app/search/SearchAutocomplete.tsx @@ -1,17 +1,26 @@ 'use client'; +import { getAutocompleteOptionId } from '@/hooks/useAutocompleteNavigation'; import { cn } from '@/utils/cn'; import { type SearchCandidate } from '@/utils/getArtistAlias'; interface SearchAutocompleteProps { autoCompleteList: SearchCandidate[]; onSelect: (value: string) => void; + /** 키보드로 이동 중인 후보. 아무것도 고르지 않은 상태는 -1 */ + activeIndex: number; + onActiveIndexChange: (index: number) => void; + /** 입력창의 aria-controls / aria-activedescendant 가 가리키는 id */ + listboxId: string; className?: string; } export default function SearchAutocomplete({ autoCompleteList, onSelect, + activeIndex, + onActiveIndexChange, + listboxId, className, }: SearchAutocompleteProps) { if (autoCompleteList.length === 0) return null; @@ -23,17 +32,25 @@ export default function SearchAutocomplete({ className, )} > -
    + {/* listbox 안의 항목은 option 이어야 한다. button 을 두면 포커스가 입력창을 떠나 + aria-activedescendant 방식(포커스는 입력창에 두고 활성 후보만 가리키는 것)이 깨진다. */} +
      {autoCompleteList.map((item, index) => ( -
    • - +
    • e.preventDefault()} + onMouseEnter={() => onActiveIndexChange(index)} + onClick={() => onSelect(item.value)} + > + {item.label}
    • ))}
    diff --git a/apps/web/src/hooks/useAutocompleteNavigation.ts b/apps/web/src/hooks/useAutocompleteNavigation.ts new file mode 100644 index 00000000..cf07fce9 --- /dev/null +++ b/apps/web/src/hooks/useAutocompleteNavigation.ts @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; + +import { type SearchCandidate } from '@/utils/getArtistAlias'; + +// 입력창(aria-activedescendant)과 옵션(id)이 같은 규칙으로 id를 만들어야 +// 스크린리더가 "지금 읽어야 할 후보"를 찾을 수 있다. +export const getAutocompleteOptionId = (listboxId: string, index: number) => + `${listboxId}-option-${index}`; + +interface Params { + /** 드롭다운 ul 의 id. 화면마다 달라야 한다 */ + listboxId: string; + /** + * 후보 목록. 참조가 매 렌더 바뀌면 활성 후보가 계속 초기화되므로 + * 호출부에서 useMemo 로 감싸 넘긴다. + */ + candidates: SearchCandidate[]; + isOpen: boolean; + /** Esc 로 닫을 때 */ + onClose: () => void; +} + +/** + * 자동완성 드롭다운의 키보드 내비게이션(↑/↓/Esc)과 ARIA 속성을 담당한다. + * + * 엔터 처리는 화면마다 의미가 달라(검색 실행 / 목록에 담기) 여기서 하지 않는다. + * 호출부가 activeCandidate 를 보고 직접 정한다. + */ +export default function useAutocompleteNavigation({ + listboxId, + candidates, + isOpen, + onClose, +}: Params) { + // 키보드로 이동 중인 후보. 아무것도 고르지 않은 상태는 -1 + const [activeIndex, setActiveIndex] = useState(-1); + + // 후보 목록이 바뀌거나 드롭다운이 닫히면 활성 후보를 되돌린다. + // 남겨두면 목록이 줄어든 뒤 엉뚱한 후보가 엔터로 선택된다. + useEffect(() => { + setActiveIndex(-1); + }, [candidates, isOpen]); + + const activeCandidate = isOpen && activeIndex >= 0 ? (candidates[activeIndex] ?? null) : null; + + // ↑/↓/Esc 는 keydown 에서 처리한다. keyup 은 이미 커서가 움직인 뒤라 늦다. + const handleKeyDown = (e: React.KeyboardEvent) => { + // 한글 조합 중의 방향키는 IME 의 몫이다. + if (e.nativeEvent.isComposing || !isOpen || candidates.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); // 커서가 입력 끝으로 튀는 기본 동작을 막는다 + setActiveIndex(prev => (prev + 1) % candidates.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex(prev => (prev <= 0 ? candidates.length - 1 : prev - 1)); + } else if (e.key === 'Escape') { + onClose(); + } + }; + + // 입력창에 그대로 펼쳐 넣는다 (WAI-ARIA combobox 패턴) + const inputAriaProps = { + role: 'combobox' as const, + 'aria-expanded': isOpen, + 'aria-controls': isOpen ? listboxId : undefined, + 'aria-activedescendant': + activeIndex >= 0 && isOpen ? getAutocompleteOptionId(listboxId, activeIndex) : undefined, + 'aria-autocomplete': 'list' as const, + }; + + // SearchAutocomplete 에 그대로 펼쳐 넣는다 + const listboxProps = { + listboxId, + activeIndex, + onActiveIndexChange: setActiveIndex, + }; + + return { + activeIndex, + setActiveIndex, + activeCandidate, + handleKeyDown, + inputAriaProps, + listboxProps, + }; +} diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index e6c9f5f0..f290adb6 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -66,9 +66,11 @@ export default function useSearchSong() { [search, canUseArtistAlias], ); - const handleSearch = () => { + // overrideText: 자동완성 후보를 키보드/마우스로 고른 경우 그 값으로 바로 검색한다. + // setSearch 는 다음 렌더에야 반영되므로, 인자로 받지 않으면 직전 입력값으로 검색이 나간다. + const handleSearch = (overrideText?: string) => { // trim 제거 - const trimSearch = search.trim(); + const trimSearch = (overrideText ?? search).trim(); if (!trimSearch) { setQuery(''); From 110bee03805577237ee655a5d8c4fd5fa19a3522 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Tue, 8 Sep 2026 20:19:09 +0900 Subject: [PATCH 9/9] =?UTF-8?q?chore=20:=20sitemap=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빌드 산출물. lastmod 타임스탬프만 갱신됐다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013T8GYG7XyLmTs1SFKMwsaA --- apps/web/public/sitemap-0.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml index ee745516..dd0e5940 100644 --- a/apps/web/public/sitemap-0.xml +++ b/apps/web/public/sitemap-0.xml @@ -1,6 +1,6 @@ -https://www.singcode.kr/manifest.webmanifest2026-08-31T07:03:47.228Zweekly0.7 -https://www.singcode.kr/patch-notes2026-08-31T07:03:47.229Zweekly0.7 -https://www.singcode.kr2026-08-31T07:03:47.229Zweekly0.7 +https://www.singcode.kr/manifest.webmanifest2026-09-08T09:58:23.352Zweekly0.7 +https://www.singcode.kr/patch-notes2026-09-08T09:58:23.354Zweekly0.7 +https://www.singcode.kr2026-09-08T09:58:23.354Zweekly0.7 \ No newline at end of file