feat(shell): left-nav menu search (UIWS NavSearch pattern) + accordion alignment

Adds a menu search box above the nav (match-highlight, Escape/clear
restores the accordion, track/admin gates preserved). Single-open
accordion with route-driven active group confirmed per owner request.
i18n keys shell.menu.search.* added across ko/en/ja/zh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 02:25:17 +09:00
parent 3860e7e88a
commit a3fdb62fc6
6 changed files with 269 additions and 28 deletions

View File

@ -1,5 +1,5 @@
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
@ -22,6 +22,7 @@ import {
IconCalendar, IconCalendar,
IconCheckCircle, IconCheckCircle,
IconChevronDown, IconChevronDown,
IconClose,
IconDashboard, IconDashboard,
IconDocument, IconDocument,
IconExhibitors, IconExhibitors,
@ -32,6 +33,7 @@ import {
IconMenu, IconMenu,
IconMoon, IconMoon,
IconOperations, IconOperations,
IconSearch,
IconSettings, IconSettings,
IconSettlement, IconSettlement,
IconSun, IconSun,
@ -225,6 +227,53 @@ function hallIdFromLabel(label: string | null | undefined): string {
return m ? `H${m[1]}` : 'H7'; return m ? `H${m[1]}` : 'H7';
} }
/**
* (WISE NavSearch/filterMenuTree ) prune.
* 매칭: 항목 (i18n ) ( ). section .
* , (WISE force-open ).
* resolveLabel i18n 4 .
*/
interface FilteredGroup {
group: NavGroupDef;
items: NavItem[];
}
function filterGroups(
groups: NavGroupDef[],
query: string,
resolveLabel: (item: NavItem) => string,
): FilteredGroup[] {
const nq = query.trim().toLowerCase();
if (!nq) return [];
const out: FilteredGroup[] = [];
for (const group of groups) {
const items = group.items.filter(
(item) => !item.section && resolveLabel(item).toLowerCase().includes(nq),
);
if (items.length) out.push({ group, items });
}
return out;
}
/** 라벨을 검색어 매칭 구간에서 분할(하이라이트용). 미매칭 시 [{text,hit:false}] 단일. */
function highlightParts(label: string, query: string): { text: string; hit: boolean }[] {
const nq = query.trim().toLowerCase();
if (!nq) return [{ text: label, hit: false }];
const parts: { text: string; hit: boolean }[] = [];
const lower = label.toLowerCase();
let i = 0;
while (i < label.length) {
const idx = lower.indexOf(nq, i);
if (idx < 0) {
parts.push({ text: label.slice(i), hit: false });
break;
}
if (idx > i) parts.push({ text: label.slice(i, idx), hit: false });
parts.push({ text: label.slice(idx, idx + nq.length), hit: true });
i = idx + nq.length;
}
return parts;
}
/** 현재 경로가 속한 대분류 그룹 id(활성 그룹 자동 펼침용). 정적 to 항목만 매칭. */ /** 현재 경로가 속한 대분류 그룹 id(활성 그룹 자동 펼침용). 정적 to 항목만 매칭. */
function activeGroupId(pathname: string): string | null { function activeGroupId(pathname: string): string | null {
for (const g of GROUPS) { for (const g of GROUPS) {
@ -265,6 +314,11 @@ export function AppShell() {
// 단일-열림 아코디언(소유자 지시 ②) — 한 번에 대분류 하나만 펼침. WISE LeftNav 동작 그대로. // 단일-열림 아코디언(소유자 지시 ②) — 한 번에 대분류 하나만 펼침. WISE LeftNav 동작 그대로.
const [openGroup, setOpenGroup] = useState<string | null>(() => activeGroupId(location.pathname)); const [openGroup, setOpenGroup] = useState<string | null>(() => activeGroupId(location.pathname));
// 메뉴 검색(WISE NavSearch 패턴) — 검색어는 셸 로컬 state(신규 store 없음).
const [menuQuery, setMenuQuery] = useState('');
const searching = menuQuery.trim().length > 0;
const clearMenuQuery = () => setMenuQuery('');
// 설정 아이콘 → 우측 레이아웃 커스터마이저(#43) offcanvas 열림. // 설정 아이콘 → 우측 레이아웃 커스터마이저(#43) offcanvas 열림.
const [customizerOpen, setCustomizerOpen] = useState(false); const [customizerOpen, setCustomizerOpen] = useState(false);
@ -300,11 +354,71 @@ export function AppShell() {
closeOnMobile(); closeOnMobile();
} }
/**
* ( ). highlight=true <mark> .
* (WISE onNavigate=clearQuery).
*/
const renderLeaf = (item: NavItem, highlight: boolean) => {
const label = navLabel(item.labelKey, item.label);
const body = highlight
? highlightParts(label, menuQuery).map((p, i) =>
p.hit ? (
<mark key={i} className="kx-shell__nav-hl">{p.text}</mark>
) : (
<span key={i}>{p.text}</span>
),
)
: label;
const onNav = () => {
if (highlight) clearMenuQuery();
closeOnMobile();
};
return item.to ? (
<NavLink
to={item.to}
className={({ isActive }) => `kx-shell__nav-link ${isActive ? 'is-active' : ''}`}
onClick={onNav}
>
<span className="kx-shell__nav-dot" aria-hidden="true" />
<span>{body}</span>
</NavLink>
) : (
<button
type="button"
className="kx-shell__nav-link kx-shell__nav-link--btn"
onClick={() => {
if (highlight) clearMenuQuery();
goScoped(item.key);
}}
>
<span className="kx-shell__nav-dot" aria-hidden="true" />
<span>{body}</span>
</button>
);
};
// 관리자 그룹은 isAdmin 게이트 유지 + 트랙 노출 매트릭스(plan §2-3). 미지정 그룹=전 트랙. // 관리자 그룹은 isAdmin 게이트 유지 + 트랙 노출 매트릭스(plan §2-3). 미지정 그룹=전 트랙.
const visibleGroups = GROUPS.filter( const visibleGroups = GROUPS.filter(
(g) => (!g.admin || isAdmin) && trackAllows(g.tracks, activeTrack), (g) => (!g.admin || isAdmin) && trackAllows(g.tracks, activeTrack),
); );
/**
* (global), (i18n) .
* (trackAllows) .
* (visibleGroups·menuQuery·activeTrack· )
*/
const searchResults = useMemo(
() =>
filterGroups(visibleGroups, menuQuery, (item) => navLabel(item.labelKey, item.label)).map(
({ group, items }) => ({
group,
items: items.filter((item) => trackAllows(item.tracks, activeTrack)),
}),
).filter(({ items }) => items.length > 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[visibleGroups, menuQuery, activeTrack, t],
);
return ( return (
<div className={`kx-shell ${navCollapsed ? 'is-nav-collapsed' : ''}`}> <div className={`kx-shell ${navCollapsed ? 'is-nav-collapsed' : ''}`}>
<a className="kx-skip-link" href="#kx-main-content"> <a className="kx-skip-link" href="#kx-main-content">
@ -335,7 +449,59 @@ export function AppShell() {
</button> </button>
)} )}
{/* 메뉴 검색(WISE NavSearch 이식) — 브랜드/행사 아래 고정(스크롤 영역 위, flex:none). */}
<div className="kx-shell__nav-search" role="search">
<IconSearch size={16} className="kx-shell__nav-search-icon" aria-hidden="true" />
<input
type="text"
className="kx-shell__nav-search-input"
value={menuQuery}
placeholder={t('shell.menu.search.placeholder', { defaultValue: '메뉴 검색' })}
aria-label={t('shell.menu.search.placeholder', { defaultValue: '메뉴 검색' })}
onChange={(e) => setMenuQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape' && menuQuery) {
e.preventDefault();
clearMenuQuery();
}
}}
/>
{menuQuery && (
<button
type="button"
className="kx-shell__nav-search-clear"
aria-label={t('shell.menu.search.clear', { defaultValue: '검색어 지우기' })}
onClick={clearMenuQuery}
>
<IconClose size={15} aria-hidden="true" />
</button>
)}
</div>
<div className="kx-shell__nav-scroll"> <div className="kx-shell__nav-scroll">
{searching ? (
/* 검색 — 전체 대분류 횡단(global) 매칭 항목만, 매칭 그룹 force-open(WISE 패턴). 하이라이트. */
searchResults.length === 0 ? (
<div className="kx-shell__nav-empty">
{t('shell.menu.search.noResult', { defaultValue: '일치하는 메뉴가 없습니다' })}
</div>
) : (
searchResults.map(({ group, items }) => (
<div className="kx-shell__group" key={group.id}>
<div className="kx-shell__group-title kx-shell__group-title--static">
<span className="kx-shell__nav-icon"><group.Icon size={18} /></span>
<span className="kx-shell__group-label">{navLabel(group.labelKey, group.label)}</span>
</div>
<ul className="kx-shell__nav kx-shell__nav--sub">
{items.map((item) => (
<li key={item.key}>{renderLeaf(item, true)}</li>
))}
</ul>
</div>
))
)
) : (
<>
{/* 홈 — 최상위 단일 링크 */} {/* 홈 — 최상위 단일 링크 */}
<ul className="kx-shell__nav"> <ul className="kx-shell__nav">
<li> <li>
@ -381,29 +547,7 @@ export function AppShell() {
{navLabel(item.labelKey, item.label)} {navLabel(item.labelKey, item.label)}
</li> </li>
) : ( ) : (
<li key={item.key}> <li key={item.key}>{renderLeaf(item, false)}</li>
{item.to ? (
<NavLink
to={item.to}
className={({ isActive }) =>
`kx-shell__nav-link ${isActive ? 'is-active' : ''}`
}
onClick={closeOnMobile}
>
<span className="kx-shell__nav-dot" aria-hidden="true" />
{navLabel(item.labelKey, item.label)}
</NavLink>
) : (
<button
type="button"
className="kx-shell__nav-link kx-shell__nav-link--btn"
onClick={() => goScoped(item.key)}
>
<span className="kx-shell__nav-dot" aria-hidden="true" />
{navLabel(item.labelKey, item.label)}
</button>
)}
</li>
), ),
)} )}
</ul> </ul>
@ -411,6 +555,8 @@ export function AppShell() {
</div> </div>
); );
})} })}
</>
)}
</div> </div>
{/* 하단 사용자 영역 — 반응형 재정렬(소유자 지적 3): 프로필 행 + 액션 아이콘 행. */} {/* 하단 사용자 영역 — 반응형 재정렬(소유자 지적 3): 프로필 행 + 액션 아이콘 행. */}

View File

@ -98,6 +98,81 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* ── 메뉴 검색(WISE NavSearch 이식) — 브랜드/행사 아래 고정(스크롤 영역 위). ── */
.kx-shell__nav-search {
position: relative;
display: flex;
align-items: center;
flex: none;
padding: var(--space-3) var(--space-4) 0;
}
.kx-shell__nav-search-icon {
position: absolute;
left: calc(var(--space-4) + 11px);
color: var(--color-neutral-500);
pointer-events: none;
}
.kx-shell__nav-search-input {
width: 100%;
height: 34px;
padding: 0 32px 0 34px;
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-pill);
background: var(--color-neutral-050);
color: var(--color-neutral-900);
font-size: var(--fs-body);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.kx-shell__nav-search-input::placeholder {
color: var(--color-neutral-500);
}
.kx-shell__nav-search-input:focus {
border-color: var(--color-primary-600);
box-shadow: 0 0 0 2px var(--color-primary-100);
}
.kx-shell__nav-search-clear {
position: absolute;
right: calc(var(--space-4) + 6px);
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-neutral-500);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.kx-shell__nav-search-clear:hover {
background: var(--color-neutral-100);
color: var(--color-neutral-900);
}
/* 검색 결과 없음/빈 메뉴 */
.kx-shell__nav-empty {
padding: var(--space-4);
font-size: var(--fs-caption);
color: var(--color-neutral-500);
text-align: center;
}
/* 검색 결과 그룹 헤더(클릭 불가, 아코디언 토글 없이 그룹 라벨만). */
.kx-shell__group-title--static {
cursor: default;
}
.kx-shell__group-title--static:hover {
background: transparent;
color: var(--color-neutral-700);
}
/* 검색어 매칭 하이라이트 */
.kx-shell__nav-hl {
background: var(--color-warning-100, var(--color-primary-100));
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
/* 메뉴 스크롤 영역 — 사이드바 중앙(하단 사용자 고정). */ /* 메뉴 스크롤 영역 — 사이드바 중앙(하단 사용자 고정). */
.kx-shell__nav-scroll { .kx-shell__nav-scroll {
flex: 1 1 auto; flex: 1 1 auto;

View File

@ -33,6 +33,11 @@
"shell": { "shell": {
"menu": { "menu": {
"home": "Home", "home": "Home",
"search": {
"placeholder": "Search menu",
"clear": "Clear search",
"noResult": "No matching menu"
},
"groups": { "groups": {
"ops": "Exhibition Ops", "ops": "Exhibition Ops",
"design": "Design & Build", "design": "Design & Build",
@ -2817,4 +2822,4 @@
"renderResult": "Result", "renderResult": "Result",
"noParams": "This template has no parameters" "noParams": "This template has no parameters"
} }
} }

View File

@ -33,6 +33,11 @@
"shell": { "shell": {
"menu": { "menu": {
"home": "ホーム", "home": "ホーム",
"search": {
"placeholder": "メニュー検索",
"clear": "検索をクリア",
"noResult": "一致するメニューがありません"
},
"groups": { "groups": {
"ops": "展示運営", "ops": "展示運営",
"design": "設計・施工", "design": "設計・施工",
@ -2817,4 +2822,4 @@
"renderResult": "レンダリング結果", "renderResult": "レンダリング結果",
"noParams": "置換パラメータのないテンプレートです" "noParams": "置換パラメータのないテンプレートです"
} }
} }

View File

@ -33,6 +33,11 @@
"shell": { "shell": {
"menu": { "menu": {
"home": "홈", "home": "홈",
"search": {
"placeholder": "메뉴 검색",
"clear": "검색어 지우기",
"noResult": "일치하는 메뉴가 없습니다"
},
"groups": { "groups": {
"ops": "전시 운영", "ops": "전시 운영",
"design": "설계·시공", "design": "설계·시공",
@ -2817,4 +2822,4 @@
"renderResult": "렌더 결과", "renderResult": "렌더 결과",
"noParams": "치환할 파라미터가 없는 템플릿입니다" "noParams": "치환할 파라미터가 없는 템플릿입니다"
} }
} }

View File

@ -33,6 +33,11 @@
"shell": { "shell": {
"menu": { "menu": {
"home": "首页", "home": "首页",
"search": {
"placeholder": "搜索菜单",
"clear": "清除搜索",
"noResult": "没有匹配的菜单"
},
"groups": { "groups": {
"ops": "展会运营", "ops": "展会运营",
"design": "设计·施工", "design": "设计·施工",
@ -2817,4 +2822,4 @@
"renderResult": "渲染结果", "renderResult": "渲染结果",
"noParams": "此模板没有可替换参数" "noParams": "此模板没有可替换参数"
} }
} }