Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions reports/completion/TSK-012-03-app-shell-subnav-grid-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# TSK-012-03 App Shell SubNav Grid Layout

Scope-ID: `TSK-012-03-APP-SHELL-SUBNAV-GRID-LAYOUT`
Issue: https://github.com/STH-1-Class-One-Group/JamIssue/issues/407
PR: https://github.com/STH-1-Class-One-Group/JamIssue/pull/414
Branch: `app-shell-subnav-grid-layout`
Status: `validated-local`
Parent Issue: https://github.com/STH-1-Class-One-Group/JamIssue/issues/404
Child Issue: https://github.com/STH-1-Class-One-Group/JamIssue/issues/407

## 목적

2차 UI/UX 구현 명세의 #407 범위를 구현한다. 앱 셸에 optional `subNav` 슬롯을 추가하고, 지도 카테고리 필터를 지도 본문 오버레이가 아니라 헤더 아래 48px flow 레이어로 이동한다.

## 변경 요약

- `AppShell`에 `subNav` prop과 `app-shell--with-subnav` / `app-shell--no-subnav` 변형을 추가했다.
- 지도 탭에서는 `AppMapStageSubNav`가 `MapStageCategoryStrip`을 shell subNav 슬롯에 렌더링한다.
- `MapTabStage`는 지도 표면과 시트만 담당하도록 축소했다.
- `.app-shell__sub-nav-slot`과 subNav 내부 `.map-filter-strip` 오버라이드를 추가했다.
- #410 범위인 광범위한 absolute/fixed CSS debt cleanup은 수행하지 않았다.

## Architecture Boundary Gate

- Responsibility map: `AppShell`은 header, optional subNav, content, bottom tab 슬롯을 소유한다. `AppMapStageSubNav`는 map stage의 subNav composition을 소유한다. `MapTabStage`는 map surface와 sheet 렌더링만 소유한다.
- Dependency direction: `App.tsx -> AppShell`이 shell slot을 조립하고, `AppMapStageView.tsx -> MapStageCategoryStrip` 방향으로 map-local UI를 위임한다. stage 내부가 shell layout을 직접 수정하지 않는다.
- Test seam: `test/unit/app-shell-subnav.test.tsx`와 `test/e2e/app-shell.spec.ts`가 AppShell public DOM slot과 active tab behavior를 검증한다.
- Scope map: 변경 파일은 App shell composition, map stage composition, 최소 CSS layout override, unit/e2e tests, completion report로 제한했다.
- Architecture risk: 기존 `.map-filter-strip`와 `.map-surface-frame` absolute offset debt는 남아 있으며 #410에서 정리해야 한다. 이번 PR은 subNav 슬롯 이동과 flow 배치 증명까지만 다룬다.

## 검증 결과

- [x] `npm.cmd run check:numeric-literals`
- [x] `npm.cmd run lint`
- [x] `npm.cmd run typecheck`
- [x] `npm.cmd run test:unit`
- [x] `npm.cmd run test:integration`
- [x] `npm.cmd run test:regression`
- [x] `npm.cmd run test:e2e`
- [x] `npm.cmd run build`
- [x] `git diff --check`
- [x] UTF-8 integrity check

## 남은 후속 작업

- #408: Event tab festival-only 정리.
- #409: KTO tourism map layer 구현.
- #410: app shell CSS offset cleanup과 남은 absolute/fixed debt 제거.
6 changes: 5 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { AppMapStageView } from './components/AppMapStageView';
import { AppMapStageSubNav, AppMapStageView } from './components/AppMapStageView';
import { AppPageStage } from './components/AppPageStage';
import { AppShell } from './components/app-shell/AppShell';
import {
Expand Down Expand Up @@ -55,6 +55,9 @@ export default function App() {
(Boolean(mapStageProps.mapData.selectedPlace) && mapStageProps.mapData.drawerState === 'full') ||
(Boolean(mapStageProps.mapData.selectedFestival) && mapStageProps.mapData.drawerState === 'full')
);
const subNav = activeTab === 'map' ? (
<AppMapStageSubNav mapData={mapStageProps.mapData} mapActions={mapStageProps.mapActions} />
) : null;

return (
<AppShell
Expand All @@ -69,6 +72,7 @@ export default function App() {
globalUtility={globalUtility}
onBottomTabChange={handleBottomNavChange}
onNavigateBack={handleNavigateBack}
subNav={subNav}
>
{activeTab === 'map' ? (
<AppMapStageView {...mapStageProps} />
Expand Down
19 changes: 15 additions & 4 deletions src/components/AppMapStageView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { memo } from 'react';
import { MapTabStage } from './MapTabStage';
import { MapStageCategoryStrip } from './map-stage/MapStageCategoryStrip';
import type { ApiStatus, Category, DrawerState, FestivalItem, Place, ReviewMood, RoutePreview } from '../types/core';
import type { SessionUser } from '../types/auth';
import type { BootstrapResponse } from '../types/review';
Expand Down Expand Up @@ -51,14 +52,27 @@ interface AppMapStageViewProps {
};
}

type AppMapStageSubNavProps = Pick<AppMapStageViewProps, 'mapData' | 'mapActions'>;

export function AppMapStageSubNav({
mapData,
mapActions,
}: AppMapStageSubNavProps) {
return (
<MapStageCategoryStrip
activeCategory={mapData.activeCategory}
onSelectCategory={mapActions.setActiveCategory}
/>
);
}

export const AppMapStageView = memo(function AppMapStageView({
mapData,
mapActions,
}: AppMapStageViewProps) {
return (
<MapTabStage
mapData={{
activeCategory: mapData.activeCategory,
filteredPlaces: mapData.filteredPlaces,
festivals: mapData.festivals,
currentPosition: mapData.currentPosition,
Expand Down Expand Up @@ -109,9 +123,6 @@ export const AppMapStageView = memo(function AppMapStageView({
onExpandFestivalDrawer: mapActions.onExpandFestivalDrawer,
onCollapseFestivalDrawer: mapActions.onCollapseFestivalDrawer,
}}
mapActions={{
setActiveCategory: mapActions.setActiveCategory,
}}
/>
);
});
6 changes: 0 additions & 6 deletions src/components/MapTabStage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { MapStageCategoryStrip } from './map-stage/MapStageCategoryStrip';
import { MapStageMapSurface } from './map-stage/MapStageMapSurface';
import { MapStageSheets } from './map-stage/MapStageSheets';
import type { MapTabStageProps } from './map-stage/mapTabStageTypes';
Expand All @@ -9,14 +8,9 @@ export function MapTabStage({
viewportData,
placeSheet,
festivalSheet,
mapActions,
}: MapTabStageProps) {
return (
<div className="map-stage">
<MapStageCategoryStrip
activeCategory={mapData.activeCategory}
onSelectCategory={mapActions.setActiveCategory}
/>
<MapStageMapSurface
mapData={mapData}
routePreviewData={routePreviewData}
Expand Down
20 changes: 19 additions & 1 deletion src/components/app-shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface AppShellProps {
globalUtility: ComponentProps<typeof GlobalSettingsMenu>;
onBottomTabChange: (nextTab: Tab) => void;
onNavigateBack: () => void;
subNav?: ReactNode;
}

export function AppShell({
Expand All @@ -25,17 +26,21 @@ export function AppShell({
globalUtility,
onBottomTabChange,
onNavigateBack,
subNav,
}: AppShellProps) {
const isMapStage = activeTab === 'map';
const hasSubNav = Boolean(subNav);

return (
<div className="map-app-shell" data-app-shell="root">
<div
className={[
'phone-shell',
isMapStage ? 'phone-shell--map' : '',
hasSubNav ? 'app-shell--with-subnav' : 'app-shell--no-subnav',
].filter(Boolean).join(' ')}
data-app-shell="phone"
data-testid="app-shell-phone"
>
{globalStatus && (
<div className="phone-shell__status-slot app-shell__status-safe-area" data-app-shell-slot="status">
Expand All @@ -51,8 +56,21 @@ export function AppShell({
globalUtility={globalUtility}
onNavigateBack={onNavigateBack}
/>
{hasSubNav && (
<div
className="app-shell__sub-nav-slot"
data-app-shell-slot="sub-nav"
data-testid="app-shell-sub-nav"
>
{subNav}
</div>
)}
<div className="phone-shell__body" data-app-shell-slot="body">
<div className="app-shell__content-slot" data-app-shell-slot="content">
<div
className="app-shell__content-slot"
data-app-shell-slot="content"
data-testid="app-shell-content"
>
{children}
</div>
<div
Expand Down
6 changes: 1 addition & 5 deletions src/components/map-stage/mapTabStageTypes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { ApiStatus, Category, DrawerState, FestivalItem, Place, ReviewMood, RoutePreview } from '../../types/core';
import type { ApiStatus, DrawerState, FestivalItem, Place, ReviewMood, RoutePreview } from '../../types/core';
import type { SessionUser } from '../../types/auth';
import type { BootstrapResponse } from '../../types/review';

export interface MapTabStageProps {
mapData: {
activeCategory: Category;
filteredPlaces: Place[];
festivals: FestivalItem[];
currentPosition: { latitude: number; longitude: number } | null;
Expand Down Expand Up @@ -55,7 +54,4 @@ export interface MapTabStageProps {
onExpandFestivalDrawer: () => void;
onCollapseFestivalDrawer: () => void;
};
mapActions: {
setActiveCategory: (category: Category) => void;
};
}
55 changes: 55 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
--app-shell-safe-y: 16px;
--app-shell-safe-x: 14px;
--app-header-height: 56px;
--app-sub-nav-height: 48px;
--app-header-gap: 10px;
--app-header-action-size: 44px;
--phone-shell-max-width: 430px;
Expand Down Expand Up @@ -176,6 +177,14 @@ textarea {
inset: var(--app-header-height) 0 0 0;
}

.app-shell--with-subnav .phone-shell__body {
inset: calc(var(--app-header-height) + var(--app-sub-nav-height)) 0 0 0;
}

.app-shell--no-subnav .phone-shell__body {
inset: var(--app-header-height) 0 0 0;
}

.app-header {
position: absolute;
top: 0;
Expand Down Expand Up @@ -276,6 +285,52 @@ textarea {
line-height: 1;
}

.app-shell__sub-nav-slot {
position: absolute;
top: var(--app-header-height);
left: 0;
right: 0;
z-index: calc(var(--layer-app-header) - 1);
height: var(--app-sub-nav-height);
display: flex;
align-items: center;
padding: 0 var(--app-shell-safe-x);
background: rgba(255, 252, 249, 0.96);
border-bottom: 1px solid rgba(255, 197, 217, 0.22);
backdrop-filter: blur(14px);
}

.app-shell__sub-nav-slot .map-filter-strip {
position: relative;
top: auto;
left: auto;
right: auto;
z-index: auto;
width: 100%;
height: 100%;
display: flex;
align-items: center;
gap: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}

.app-shell__sub-nav-slot .map-filter-strip .chip-row {
width: 100%;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
padding: 2px 0;
scrollbar-width: none;
}

.app-shell__sub-nav-slot .map-filter-strip .chip-row::-webkit-scrollbar {
display: none;
}

.app-shell__content-slot {
position: absolute;
inset: 0;
Expand Down
27 changes: 27 additions & 0 deletions test/e2e/app-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,30 @@ test('UIUX-014 keeps tab content surfaces accessible inside the app shell', asyn
expect(surfaceBox.x + surfaceBox.width).toBeLessThanOrEqual(contentSlotBox.x + contentSlotBox.width + 1);
}
});

test('UIUX-015 moves map filters into the app shell sub navigation flow', async ({ page }) => {
await installApiFixtures(page, createE2EAppState({ authenticated: false }));

await page.goto('/');

const phoneShell = page.locator('[data-app-shell="phone"]');
const subNavSlot = page.locator('[data-app-shell-slot="sub-nav"]');
const filterStrip = subNavSlot.locator('.map-filter-strip');

await expect(phoneShell).toHaveClass(/app-shell--with-subnav/);
await expect(subNavSlot).toBeVisible();
await expect(filterStrip).toBeVisible();
await expect(page.locator('.map-stage > .map-filter-strip')).toHaveCount(0);

const headerBox = await requireBoundingBox(page.locator('[data-app-shell-slot="header"]'));
const subNavBox = await requireBoundingBox(subNavSlot);
const contentBox = await requireBoundingBox(page.locator('[data-app-shell-slot="content"]'));

expect(subNavBox.y).toBeGreaterThanOrEqual(headerBox.y + headerBox.height - 1);
expect(contentBox.y).toBeGreaterThanOrEqual(subNavBox.y + subNavBox.height - 1);

await page.locator('[data-tab-key="my"]').click();

await expect(phoneShell).toHaveClass(/app-shell--no-subnav/);
await expect(page.locator('[data-app-shell-slot="sub-nav"]')).toHaveCount(0);
});
64 changes: 64 additions & 0 deletions test/unit/app-shell-subnav.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* File: app-shell-subnav.test.tsx
* Purpose: Verify the TSK-012 app shell sub-navigation slot contract.
* Primary Responsibility: Prove that optional sub navigation is owned by AppShell rather than map-stage overlays.
* Design Intent: Exercise the AppShell public props so layout internals can change without rewriting tests.
* Non-Goals: This test does not validate KTO map markers or broad CSS cleanup covered by later child issues.
* Dependencies: React Testing Library, Vitest, and the AppShell component.
*/
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { AppShell } from '../../src/components/app-shell/AppShell';

const globalUtility = {
sessionUserName: 'tester',
notifications: [],
unreadCount: 0,
onOpenNotification: vi.fn(),
onMarkAllNotificationsRead: vi.fn(),
onDeleteNotification: vi.fn(),
};

function renderShell(subNav?: ReactNode) {
render(
<AppShell
activeTab="map"
canNavigateBack={false}
globalStatus={null}
globalUtility={globalUtility}
onBottomTabChange={vi.fn()}
onNavigateBack={vi.fn()}
subNav={subNav}
>
<div data-testid="content">content</div>
</AppShell>,
);
}

describe('AppShell sub navigation contract', () => {
it('renders sub navigation as a shell slot between header and content', () => {
renderShell(<nav aria-label="stage filters">filters</nav>);

const phoneShell = screen.getByTestId('app-shell-phone');
const subNavSlot = screen.getByTestId('app-shell-sub-nav');
const contentSlot = screen.getByTestId('app-shell-content');

expect(phoneShell).toHaveClass('app-shell--with-subnav');
expect(phoneShell).not.toHaveClass('app-shell--no-subnav');
expect(subNavSlot).toHaveAttribute('data-app-shell-slot', 'sub-nav');
expect(subNavSlot).toHaveTextContent('filters');
expect(contentSlot).toContainElement(screen.getByTestId('content'));
});

it('uses the no-subnav variant when a screen has no stage-level navigation', () => {
renderShell();

const phoneShell = screen.getByTestId('app-shell-phone');

expect(phoneShell).toHaveClass('app-shell--no-subnav');
expect(phoneShell).not.toHaveClass('app-shell--with-subnav');
expect(screen.queryByTestId('app-shell-sub-nav')).not.toBeInTheDocument();
expect(screen.getByTestId('app-shell-content')).toContainElement(screen.getByTestId('content'));
});
});
Loading