Skip to content
Open
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
89 changes: 89 additions & 0 deletions docs/impl/sidebar-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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に戻っている
```

---

## アーキテクチャ設計

### 現状の問題点
Expand Down
94 changes: 94 additions & 0 deletions e2e/layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
14 changes: 12 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export async function createApp(script: Script, options?: KantanAppOptions): Pro

// サイドバーがある場合のHTML構造を生成
const bodyContent = initialResult.hasSidebar
? html`<div class="kt-layout-sidebar">
? html`<div class="kt-layout-sidebar" style="--kt-sidebar-width: ${initialResult.sidebarWidth};">
<aside class="kt-sidebar" data-state="expanded">
<div id="kt-sidebar-content" class="kt-sidebar-content">${raw(initialResult.sidebarHtml)}</div>
<button class="kt-sidebar-toggle" type="button" aria-label="Toggle sidebar">
Expand Down Expand Up @@ -365,7 +365,17 @@ export async function createApp(script: Script, options?: KantanAppOptions): Pro
}

// サイドバーの差分計算
if (newResult.hasSidebar && newResult.sidebarHtml !== session.lastSidebarHtml) {
if (newResult.hasSidebar && session.lastSidebarHtml !== undefined) {
const sidebarDiffResult = diff(session.lastSidebarHtml, newResult.sidebarHtml);
const sidebarFullHtml = `<div id="kt-sidebar-content" class="kt-sidebar-content">${newResult.sidebarHtml}</div>`;
const sidebarPatches = toWebSocketPatches(
sidebarDiffResult,
sidebarFullHtml,
"kt-sidebar-content",
);
patches.push(...sidebarPatches);
} else if (newResult.hasSidebar) {
// 初回レンダリング時はreplaceNode
patches.push({
type: "replaceNode",
id: "kt-sidebar-content",
Expand Down
36 changes: 24 additions & 12 deletions src/diff/differ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,24 +81,36 @@ export function diff(oldHtml: string, newHtml: string): DiffResult {
/**
* 差分パッチをWebSocketパッチ形式に変換
* 差分が多すぎる場合やID追跡できない変更がある場合はreplaceRootにフォールバック
*
* @param diffResult - 差分計算結果
* @param fullHtml - フォールバック用の完全HTML
* @param rootId - フォールバック時のターゲットID(指定時はreplaceNodeを使用)
*/
export function toWebSocketPatches(diffResult: DiffResult, fullHtml: string): Patch[] {
export function toWebSocketPatches(
diffResult: DiffResult,
fullHtml: string,
rootId?: string,
): Patch[] {
if (!diffResult.hasChanges) {
return [];
}

// ID追跡できない変更がある場合(hasChangesはtrueだがpatchesが空)
// または差分が多すぎる場合はreplaceRootにフォールバック
if (diffResult.patches.length === 0 || diffResult.patches.length > PATCH_THRESHOLD) {
return [{ type: "replaceRoot", html: fullHtml } satisfies ReplaceRootPatch];
}
// フォールバック条件
const shouldFallback =
diffResult.patches.length === 0 ||
diffResult.patches.length > PATCH_THRESHOLD ||
diffResult.patches.some((p) => p.type === "insert");

// insertパッチがある場合はreplaceRootにフォールバック
// insertのインデックスはID付き要素間の順序で計算されるが、
// クライアント側ではすべてのDOM子要素に対してインデックスを適用するため、
// ID無し要素が混在している場合に誤った位置に挿入される
const hasInsertPatches = diffResult.patches.some((p) => p.type === "insert");
if (hasInsertPatches) {
if (shouldFallback) {
if (rootId) {
return [
{
type: "replaceNode",
id: rootId,
html: fullHtml,
} satisfies ReplaceNodePatch,
];
}
return [{ type: "replaceRoot", html: fullHtml } satisfies ReplaceRootPatch];
}

Expand Down
16 changes: 16 additions & 0 deletions src/kt/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class RenderContext {
private flushCallback: FlushCallback | null = null;
private flushThreshold = 0; // 0 = 無効(フラッシュしない)
private flushedCount = 0;
private sidebarWidth = "280px";

/**
* フラッシュコールバックを設定
Expand Down Expand Up @@ -108,6 +109,20 @@ export class RenderContext {
return this.sidebarBuffer.length > 0;
}

/**
* サイドバーの幅を設定
*/
setSidebarWidth(width: string): void {
this.sidebarWidth = width;
}

/**
* サイドバーの幅を取得
*/
getSidebarWidth(): string {
return this.sidebarWidth;
}

/**
* バッファを結合してHTMLを取得(後方互換性のため維持、メインのみ返す)
*/
Expand All @@ -123,6 +138,7 @@ export class RenderContext {
this.sidebarBuffer = [];
this.currentTarget = "main";
this.flushedCount = 0;
this.sidebarWidth = "280px";
}

/**
Expand Down
21 changes: 13 additions & 8 deletions src/kt/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,25 +253,30 @@ export interface SidebarConfig {
* @param content - サイドバー内に表示するコンテンツ
* @param config - オプション設定
*
* @remarks
* - ネスト呼び出しがサポートされています
* - 内側の `kt.sidebar()` 内のコンテンツもサイドバーバッファに出力されます
* - コールバック内で例外が発生しても、ターゲットは `try/finally` により正しく復元されます
*
* @example
* ```typescript
* kt.sidebar(() => {
* kt.title("Settings");
* const theme = kt.selectbox("Theme", ["Light", "Dark"]);
* kt.divider();
* if (kt.button("Reset")) {
* // リセット処理
* }
* kt.write("Sidebar content");
* });
*
* // メインコンテンツ
* kt.title("Main Content");
* kt.write("This is the main area.");
* kt.title("Main");
* ```
*/
export function sidebar(content: () => void, _config?: SidebarConfig): void {
export function sidebar(content: () => void, config?: SidebarConfig): void {
const ctx = requireRenderContext();

// 幅の設定(指定があれば更新)
if (config?.width) {
ctx.setSidebarWidth(config.width);
}

// ターゲットをサイドバーに切り替え
const previousTarget = ctx.getTarget();
ctx.setTarget("sidebar");
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/rerun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface RerunResult {
sidebarHtml: string;
/** サイドバーが使用されているか */
hasSidebar: boolean;
/** サイドバーの幅 */
sidebarWidth: string;
}

/**
Expand Down Expand Up @@ -98,13 +100,15 @@ export function rerun(
mainHtml: result,
sidebarHtml: "",
hasSidebar: false,
sidebarWidth: "280px",
};
}

return {
mainHtml: renderContext.getMainHtml(),
sidebarHtml: renderContext.getSidebarHtml(),
hasSidebar: renderContext.hasSidebar(),
sidebarWidth: renderContext.getSidebarWidth(),
};
} finally {
// コンテキストをクリア
Expand Down
Loading
Loading