App Shell (ui-cals)
@vortex/ui-cals의 내비게이션 스토어(createNavigationStore)와 화면 유지 컴포넌트(CachedOutlet) 사용법입니다. 메뉴 트리·열린 탭·breadcrumb·즐겨찾기 상태를 하나의 zustand 스토어로 다룹니다.
개요
| 이름 | 역할 |
|---|---|
createNavigationStore | 옵션으로 탭 키·개수·저장·닫기 규칙을 정해 내비게이션 스토어를 만든다 |
CachedOutlet | 방문한 화면을 숨겨 유지하고, 탭에서 빠진 화면을 지운다 (선택) |
메뉴 데이터 출처, 화면 메타, 권한, 이벤트 실행은 스토어가 다루지 않습니다. 앱이 옵션 콜백으로 연결합니다.
스토어 만들기
import { createNavigationStore, type NavigationItem } from "@vortex/ui-cals"
export const useAppMenu = createNavigationStore()기본값은 개수 제한 없음, 저장 안 함, 즐겨찾기 끔입니다. 필요한 것만 옵션으로 켭니다.
옵션
| 옵션 | 타입 | 기본값 | 설명 |
|---|---|---|---|
getKey | (item: T) => string | item.url | 탭·즐겨찾기·findByKey·breadcrumb의 키 |
maxTabs | number | 제한 없음 | 최대 탭 수 |
overflow | "drop-oldest" | "reject" | "drop-oldest" | 최대 탭 수를 넘을 때 오래된 탭을 닫을지, 새 탭을 막을지 |
bookmarks | boolean | false | 즐겨찾기 사용 |
persist | { key; include?; storage? } | 저장 안 함 | 저장소 키·저장할 상태·저장소 |
onBeforeClose | (tab: T) => boolean | - | false면 그 탭은 닫히지 않는다 |
onClose | (tabs: T[]) => void | - | 탭이 빠진 뒤 빠진 탭으로 한 번 부른다(모든 닫기 경로) |
isPinned | (tab: T) => boolean | - | 닫히지 않는 탭 |
closeDialogScope | boolean | true | 탭이 빠질 때 그 탭 키 scope의 dialog를 닫는다 |
getBreadcrumb | (key: string, items: T[]) => T[] | 메뉴 트리 조상 | breadcrumb 계층 규칙 |
createRouteTab | (url: string, title?: string) => T | null | 경로·제목으로 만든 항목 | 메뉴에 없는 경로의 탭. null이면 열지 않는다 |
상태와 액션
| 이름 | 설명 |
|---|---|
items / setMenuItems(items) | 메뉴 트리 |
tabs | 열린 탭 |
push(item) | 탭을 연다. 같은 키가 있으면 그대로 둔다. 탭이 열려 있으면 true |
close(key) | 키로 탭을 닫는다. { closed, next }를 돌려준다 |
update(key, patch) | 탭을 제자리에서 고친다. 없거나 바뀐 키가 겹치면 false |
closeOthers(key) / closeAll() | 닫을 수 있는 탭만 닫고, 닫힌 탭을 돌려준다 |
findByKey(key) / findByUrl(url) | 메뉴 트리에서 항목을 찾는다 |
openFromRoute(url, title?) | 경로로 탭을 연다. 메뉴에 있으면 그 항목, 없으면 createRouteTab 결과 |
getBreadcrumbItems(key) | breadcrumb 항목(label·href·type) |
bookmarks, toggleBookmark(item) 등 | 즐겨찾기. bookmarks: true일 때만 동작 |
setMaxTabs(n) | 최대 탭 수를 바꾼다. 0이면 제한 없음 |
설정 예시
키와 닫기 규칙을 앱이 정하는 탭
화면마다 고유 ID가 있고, 같은 경로라도 ID가 다르면 다른 탭으로 엽니다. 닫기 전 확인과 고정 탭, 저장 키를 앱이 정합니다.
interface ScreenTab extends NavigationItem {
id: string
items?: ScreenTab[]
}
export const useScreenTabs = createNavigationStore<ScreenTab>({
getKey: (tab) => tab.id,
onBeforeClose: (tab) => !isBusy(tab.id), // 앱의 판단
isPinned: (tab) => tab.url === "/",
persist: { key: "my-app:tabs" },
})URL 없는 탭
경로 이동 없이 내부 상태로만 화면을 바꾸는 편집기형 앱입니다. 키를 ID로 두면 url이 없어도 탭이 열립니다.
const useEditorTabs = createNavigationStore<ScreenTab>({ getKey: (tab) => tab.id })
useEditorTabs.getState().push({ id: "doc-1", title: "문서 1" })기본 설정
const useSimpleMenu = createNavigationStore() // 키 url, 제한 없음, 저장 안 함탭 닫기와 다음 탭
close는 이동하지 않습니다. 닫힌 탭이 활성 탭이었다면 돌려받은 next로 앱이 이동합니다. 키 기본값이 url이라 아래 예시는 url을 키로 넘깁니다.
const { pathname, navigate } = useRouterContext()
function closeTab(key: string) {
const wasActive = key === pathname
const { closed, next } = useAppMenu.getState().close(key)
if (closed && wasActive) navigate(next?.url ?? "/")
}탭 바 잇기 (TagView)
닫기 확인은 스토어 onBeforeClose 한 곳에 둡니다. TagView는 닫기 요청을 스토어에 넘기기만 합니다.
import { TagView } from "@vortex/ui-cals"
const tabs = useScreenTabs((s) => s.tabs)
const close = useScreenTabs((s) => s.close)
<TagView
items={tabs.map((t) => ({ value: t.id, label: t.title }))} // value는 스토어 키
closable
onClose={(v) => close(v)}
/>제자리 수정과 닫힌 뒤 정리
// 제목만 바꾼다. 탭 순서는 그대로다
useScreenTabs.getState().update("doc-1", { title: "문서 1 (수정됨)" })
// 어느 경로로 닫혀도(닫기·다른 탭 닫기·전체 닫기·최대 개수 정리) 한 번 불린다
createNavigationStore<ScreenTab>({
getKey: (tab) => tab.id,
onClose: (tabs) => tabs.forEach((t) => releaseScreen(t.id)), // 앱의 정리
})url 메서드
findByUrl·openFromRoute·toggleBookmarkByUrl은 라우팅 연동용입니다. 경로에서 탭을 찾거나 열 때 쓰고, 탭 조작은 키로 합니다.
경로가 바뀔 때 탭 열기
CachedOutlet 없이 라우터 출력만 쓰는 앱은 경로가 바뀔 때 직접 openFromRoute를 부릅니다. 라우터 출력은 useRouterContext()의 outlet으로 받습니다.
import { useEffect } from "react"
import { useRouterContext } from "@vortex/ui-cals"
function RouteOutlet() {
const { pathname, routeTitle, outlet } = useRouterContext()
const openFromRoute = useAppMenu((s) => s.openFromRoute)
useEffect(() => {
openFromRoute(pathname, routeTitle)
}, [openFromRoute, pathname, routeTitle])
return outlet
}탭을 경로가 아닌 기준(화면 메타 등)으로 여는 앱은 이 effect 대신 자기 시점에 push합니다.
화면 유지 (CachedOutlet)
기본은 라우터 출력입니다(위 「경로가 바뀔 때 탭 열기」). 화면을 바꿀 때마다 새로 마운트되고, 남겨야 할 상태는 앱 스토어에 둡니다.
탭을 바꿔도 입력값·스크롤을 컴포넌트 인스턴스째 남겨야 할 때만 CachedOutlet을 씁니다. 경로가 바뀌면 store의 openFromRoute를 부르고, 탭에서 빠진 경로의 화면을 지웁니다.
<ViteRouterAdapter>
<CachedOutlet store={useAppMenu} />
</ViteRouterAdapter>store는 필수입니다. createNavigationStore로 만든 스토어를 넘깁니다.
캐시는 경로 기준입니다. url이 있는 탭만 화면이 유지되고, url이 같은 탭은 화면 하나를 공유합니다.