diff --git a/docs/impl/sidebar-design.md b/docs/impl/sidebar-design.md index c3024cf1..e8501b8a 100644 --- a/docs/impl/sidebar-design.md +++ b/docs/impl/sidebar-design.md @@ -470,6 +470,95 @@ export interface PageConfig { --- +## ネスト呼び出しの挙動 + +### サポートされるパターン + +`kt.sidebar()` はネストして呼び出すことができます。内側の `kt.sidebar()` 呼び出し内のコンテンツも、すべてサイドバーバッファに出力されます。 + +```typescript +kt.sidebar(() => { + kt.write("Level 1"); + + kt.sidebar(() => { + kt.write("Level 2 - still in sidebar"); + }); + + kt.write("Back to Level 1 - still in sidebar"); +}); + +kt.write("Main content"); +``` + +**出力結果:** +- サイドバー: "Level 1", "Level 2 - still in sidebar", "Back to Level 1 - still in sidebar" +- メイン: "Main content" + +### 実装詳細 + +`kt.sidebar()` は内部で以下の処理を行います: + +1. 現在のターゲット(`main` または `sidebar`)を保存 +2. ターゲットを `sidebar` に切り替え +3. コールバックを実行 +4. ターゲットを元に戻す(`try/finally` で保証) + +```typescript +export function sidebar(content: () => void, config?: SidebarConfig): void { + const ctx = requireRenderContext(); + const previousTarget = ctx.getTarget(); // "main" or "sidebar" + ctx.setTarget("sidebar"); + + try { + content(); + } finally { + ctx.setTarget(previousTarget); // 必ず復元 + } +} +``` + +ネスト呼び出し時: +- 外側の `sidebar()` で `previousTarget = "main"`、`currentTarget = "sidebar"` +- 内側の `sidebar()` で `previousTarget = "sidebar"`、`currentTarget = "sidebar"` +- 内側終了時に `currentTarget = "sidebar"`(変わらず) +- 外側終了時に `currentTarget = "main"`(復元) + +### 非推奨パターン + +以下のパターンは技術的には動作しますが、コードの可読性のため推奨しません: + +```typescript +// 非推奨: 深いネスト +kt.sidebar(() => { + kt.sidebar(() => { + kt.sidebar(() => { + kt.write("Deeply nested"); + }); + }); +}); + +// 推奨: フラットな構造 +kt.sidebar(() => { + kt.write("All sidebar content here"); +}); +``` + +### エラーハンドリング + +コールバック内で例外が発生しても、ターゲットは正しく復元されます: + +```typescript +kt.sidebar(() => { + kt.write("Before error"); + throw new Error("Something went wrong"); + kt.write("After error"); // 実行されない +}); + +kt.write("This goes to main"); // ターゲットは正しくmainに戻っている +``` + +--- + ## アーキテクチャ設計 ### 現状の問題点 diff --git a/e2e/layout.spec.ts b/e2e/layout.spec.ts index f12ac50e..8472b3e7 100644 --- a/e2e/layout.spec.ts +++ b/e2e/layout.spec.ts @@ -96,6 +96,38 @@ test.describe("Layout API - Sidebar", () => { // サイドバーのカウントも更新される(再レンダリング後) await expect(sidebar).toContainText("Counter: 1"); }); + + test("sidebar counter increments correctly through diff updates", async ({ page }) => { + await gotoAndWait(page); + + const sidebar = page.locator(".kt-sidebar"); + const incrementBtn = page.locator('[data-kt-event="click"]', { hasText: "+ Increment" }); + + // 複数回インクリメントして差分更新が正しく動作することを確認 + await incrementBtn.click(); + await expect(sidebar).toContainText("Counter: 1"); + + await incrementBtn.click(); + await expect(sidebar).toContainText("Counter: 2"); + + await incrementBtn.click(); + await expect(sidebar).toContainText("Counter: 3"); + }); + + test("sidebar structure remains intact after updates", async ({ page }) => { + await gotoAndWait(page); + + const sidebarContent = page.locator("#kt-sidebar-content"); + const incrementBtn = page.locator('[data-kt-event="click"]', { hasText: "+ Increment" }); + + // サイドバーコンテンツ要素が存在することを確認 + await expect(sidebarContent).toBeVisible(); + + // 更新後もサイドバーコンテンツ要素が維持されることを確認 + await incrementBtn.click(); + await expect(sidebarContent).toBeVisible(); + await expect(sidebarContent).toContainText("Settings"); + }); }); test.describe("Layout API - Columns", () => { @@ -204,3 +236,65 @@ test.describe("Layout API - Expander", () => { await expect(content).toContainText("This content is visible by default"); }); }); + +test.describe("Layout API - Sidebar (Mobile)", () => { + test.use({ viewport: { width: 375, height: 667 } }); + + test("sidebar uses fixed positioning on mobile", async ({ page }) => { + await gotoAndWait(page); + const sidebar = page.locator(".kt-sidebar"); + await expect(sidebar).toHaveCSS("position", "fixed"); + }); + + test("sidebar toggle button is larger on mobile", async ({ page }) => { + await gotoAndWait(page); + const toggle = page.locator(".kt-sidebar-toggle"); + const box = await toggle.boundingBox(); + expect(box?.width).toBeGreaterThanOrEqual(40); + expect(box?.height).toBeGreaterThanOrEqual(40); + }); + + test("sidebar overlay appears when expanded on mobile", async ({ page }) => { + await gotoAndWait(page); + const overlay = page.locator(".kt-sidebar-overlay"); + const sidebar = page.locator(".kt-sidebar"); + + await expect(sidebar).toHaveAttribute("data-state", "expanded"); + await expect(overlay).toBeVisible(); + }); + + test("clicking overlay closes sidebar on mobile", async ({ page }) => { + await gotoAndWait(page); + const overlay = page.locator(".kt-sidebar-overlay"); + const sidebar = page.locator(".kt-sidebar"); + + await overlay.click(); + await expect(sidebar).toHaveAttribute("data-state", "collapsed"); + }); +}); + +test.describe("Layout API - Sidebar (Tablet 768px)", () => { + test.use({ viewport: { width: 768, height: 1024 } }); + + test("sidebar uses fixed positioning at exactly 768px", async ({ page }) => { + await gotoAndWait(page); + const sidebar = page.locator(".kt-sidebar"); + await expect(sidebar).toHaveCSS("position", "fixed"); + }); +}); + +test.describe("Layout API - Sidebar (Desktop)", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("sidebar uses relative positioning on desktop", async ({ page }) => { + await gotoAndWait(page); + const sidebar = page.locator(".kt-sidebar"); + await expect(sidebar).toHaveCSS("position", "relative"); + }); + + test("sidebar overlay is hidden on desktop", async ({ page }) => { + await gotoAndWait(page); + const overlay = page.locator(".kt-sidebar-overlay"); + await expect(overlay).not.toBeVisible(); + }); +}); diff --git a/src/app.ts b/src/app.ts index 455fc205..8cd4590f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -135,7 +135,7 @@ export async function createApp(script: Script, options?: KantanAppOptions): Pro // サイドバーがある場合のHTML構造を生成 const bodyContent = initialResult.hasSidebar - ? html`