Skip to content

Commit 6f97dbd

Browse files
christian-huehn-mwclaude
authored andcommitted
feat(visualization): add collapsible mode to File Explorer sidebar
Adds an in-memory collapse toggle for the left-side File Explorer drawer: - A new `«` button at the right end of the EXPLORER header collapses the sidebar to a small floating row pinned at the top-left, freeing the codemap area. The collapsed widget contains a folder-tree expand button plus the existing search input, clear button, and kebab menu (Flatten / Exclude). Re-expand returns to the full layout. - State lives in a root-provided signal-backed `ExplorerCollapseService`; no persistence — the sidebar opens expanded on every reload. - The search-bar kebab is migrated from DaisyUI's `dropdown` (clipped by the host's `overflow-hidden` in collapsed mode) to the native `[popover]` API + CSS anchor positioning, matching the pattern used by the FLATTENED / HIDDEN rule popovers. The menu now renders in the browser's top-layer and works in both modes. - Feature components stay zoneless-compatible (signals only, OnPush, no setTimeout/NgZone/CDR). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e2e5982 commit 6f97dbd

10 files changed

Lines changed: 491 additions & 46 deletions

plans/2026-05-05-collapsible-sidebar-explorer.md

Lines changed: 293 additions & 0 deletions
Large diffs are not rendered by default.

visualization/app/codeCharta/features/sidebarExplorer/components/explorerHeader/explorerHeader.component.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
<div class="flex items-center px-2 py-2 border-b border-base-300">
1+
<div class="flex items-center justify-between px-2 py-2 border-b border-base-300">
22
<span class="text-xs font-semibold uppercase tracking-wide">Explorer</span>
3+
<button
4+
type="button"
5+
class="btn btn-ghost btn-xs btn-square"
6+
(click)="collapse()"
7+
title="Collapse explorer"
8+
data-testid="explorer-collapse-button"
9+
>
10+
<i class="fa fa-angle-double-left"></i>
11+
</button>
312
</div>
413

514
<div class="flex items-stretch border border-base-300 mx-2 mt-2 rounded-md overflow-hidden divide-x divide-base-300">

visualization/app/codeCharta/features/sidebarExplorer/components/explorerHeader/explorerHeader.component.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { TestBed } from "@angular/core/testing"
22
import { provideMockStore } from "@ngrx/store/testing"
33
import { render, screen } from "@testing-library/angular"
4+
import userEvent from "@testing-library/user-event"
45
import { defaultState } from "../../../../state/store/state.manager"
56
import { explorerCountsSelector } from "../../selectors/sidebarExplorer.selectors"
7+
import { ExplorerCollapseService } from "../../services/explorerCollapse.service"
68
import { ExplorerHeaderComponent } from "./explorerHeader.component"
79

810
describe("ExplorerHeaderComponent", () => {
@@ -53,4 +55,17 @@ describe("ExplorerHeaderComponent", () => {
5355
expect(flattenedChip.querySelector("[popovertarget='explorer-flatten-rules']")).not.toBe(null)
5456
expect(hiddenChip.querySelector("[popovertarget='explorer-hidden-rules']")).not.toBe(null)
5557
})
58+
59+
it("should toggle the ExplorerCollapseService when the collapse button is clicked", async () => {
60+
// Arrange
61+
await render(ExplorerHeaderComponent)
62+
const collapseService = TestBed.inject(ExplorerCollapseService)
63+
expect(collapseService.isCollapsed()).toBe(false)
64+
65+
// Act
66+
await userEvent.click(screen.getByTestId("explorer-collapse-button"))
67+
68+
// Assert
69+
expect(collapseService.isCollapsed()).toBe(true)
70+
})
5671
})

visualization/app/codeCharta/features/sidebarExplorer/components/explorerHeader/explorerHeader.component.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/core"
22
import { toSignal } from "@angular/core/rxjs-interop"
3+
import { ExplorerCollapseService } from "../../services/explorerCollapse.service"
34
import { ExplorerCountsService } from "../../services/explorerCounts.service"
45
import { ExplorerCountChipComponent } from "../explorerCountChip/explorerCountChip.component"
56

@@ -11,6 +12,7 @@ import { ExplorerCountChipComponent } from "../explorerCountChip/explorerCountCh
1112
})
1213
export class ExplorerHeaderComponent {
1314
private readonly countsService = inject(ExplorerCountsService)
15+
private readonly collapseService = inject(ExplorerCollapseService)
1416

1517
readonly counts = toSignal(this.countsService.counts$, { requireSync: true })
1618

@@ -19,4 +21,8 @@ export class ExplorerHeaderComponent {
1921
readonly hidden = computed(() => this.counts().hidden)
2022
readonly noArea = computed(() => this.counts().noArea)
2123
readonly shownTooltip = computed(() => `${this.shown()} visible · ${this.noArea()} with no area in current metric`)
24+
25+
collapse() {
26+
this.collapseService.toggle()
27+
}
2228
}

visualization/app/codeCharta/features/sidebarExplorer/components/explorerSearchBar/explorerSearchBar.component.html

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,36 +21,51 @@
2121
}
2222
</label>
2323

24-
<div class="dropdown dropdown-end">
25-
<button type="button" tabindex="0" class="btn btn-ghost btn-sm btn-square" title="Add to Blacklist">
26-
<i class="fa fa-ellipsis-v"></i>
27-
</button>
28-
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-10 w-44 p-2 shadow">
29-
@if (isSearchPatternEmpty()) {
30-
<li class="px-2 py-1 text-xs opacity-60">Enter a pattern to enable Flatten/Exclude</li>
31-
}
32-
<li>
33-
<button
34-
type="button"
35-
data-testid="search-bar-flatten-button"
36-
[disabled]="isFlattenPatternDisabled()"
37-
(click)="flattenPattern()"
38-
>
39-
<i class="fa fa-eye-slash"></i>
40-
Flatten
41-
</button>
42-
</li>
43-
<li>
44-
<button
45-
type="button"
46-
data-testid="search-bar-exclude-button"
47-
[disabled]="isExcludePatternDisabled()"
48-
(click)="excludePattern()"
49-
>
50-
<i class="fa fa-ban"></i>
51-
Exclude
52-
</button>
53-
</li>
54-
</ul>
55-
</div>
24+
<button
25+
type="button"
26+
class="btn btn-ghost btn-sm btn-square"
27+
popovertarget="explorer-search-actions"
28+
style="anchor-name: --explorer-search-actions"
29+
title="Add to Blacklist"
30+
data-testid="search-bar-actions-trigger"
31+
>
32+
<i class="fa fa-ellipsis-v"></i>
33+
</button>
34+
35+
<ul
36+
popover
37+
id="explorer-search-actions"
38+
style="position-anchor: --explorer-search-actions"
39+
class="dropdown menu bg-base-100 rounded-box w-44 p-2 shadow-lg"
40+
>
41+
@if (isSearchPatternEmpty()) {
42+
<li class="px-2 py-1 text-xs opacity-60">Enter a pattern to enable Flatten/Exclude</li>
43+
}
44+
<li>
45+
<button
46+
type="button"
47+
data-testid="search-bar-flatten-button"
48+
popovertarget="explorer-search-actions"
49+
popovertargetaction="hide"
50+
[disabled]="isFlattenPatternDisabled()"
51+
(click)="flattenPattern()"
52+
>
53+
<i class="fa fa-eye-slash"></i>
54+
Flatten
55+
</button>
56+
</li>
57+
<li>
58+
<button
59+
type="button"
60+
data-testid="search-bar-exclude-button"
61+
popovertarget="explorer-search-actions"
62+
popovertargetaction="hide"
63+
[disabled]="isExcludePatternDisabled()"
64+
(click)="excludePattern()"
65+
>
66+
<i class="fa fa-ban"></i>
67+
Exclude
68+
</button>
69+
</li>
70+
</ul>
5671
</div>
Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1-
<cc-explorer-header></cc-explorer-header>
2-
<cc-explorer-search-bar></cc-explorer-search-bar>
3-
<cc-explorer-sort-control></cc-explorer-sort-control>
4-
<div id="explorer-scroll" class="flex-1 overflow-auto px-1 py-1">
5-
<cc-explorer-tree></cc-explorer-tree>
6-
</div>
7-
<cc-rules-popover kind="flatten" popoverId="explorer-flatten-rules" anchorName="explorer-flat-chip"></cc-rules-popover>
8-
<cc-rules-popover kind="exclude" popoverId="explorer-hidden-rules" anchorName="explorer-hidden-chip"></cc-rules-popover>
1+
@if (isCollapsed()) {
2+
<div class="flex items-center gap-1 px-2 py-1">
3+
<button
4+
type="button"
5+
class="btn btn-ghost btn-sm btn-square"
6+
(click)="toggle()"
7+
title="Expand explorer"
8+
data-testid="explorer-expand-button"
9+
>
10+
<i class="fa-solid fa-folder-tree"></i>
11+
</button>
12+
<cc-explorer-search-bar class="flex-1"></cc-explorer-search-bar>
13+
</div>
14+
} @else {
15+
<cc-explorer-header></cc-explorer-header>
16+
<cc-explorer-search-bar></cc-explorer-search-bar>
17+
<cc-explorer-sort-control></cc-explorer-sort-control>
18+
<div id="explorer-scroll" class="flex-1 overflow-auto px-1 py-1">
19+
<cc-explorer-tree></cc-explorer-tree>
20+
</div>
21+
<cc-rules-popover kind="flatten" popoverId="explorer-flatten-rules" anchorName="explorer-flat-chip"></cc-rules-popover>
22+
<cc-rules-popover kind="exclude" popoverId="explorer-hidden-rules" anchorName="explorer-hidden-chip"></cc-rules-popover>
23+
}

visualization/app/codeCharta/features/sidebarExplorer/components/sidebarExplorer/sidebarExplorer.component.spec.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { TestBed } from "@angular/core/testing"
22
import { State } from "@ngrx/store"
33
import { provideMockStore } from "@ngrx/store/testing"
4-
import { render } from "@testing-library/angular"
4+
import { render, screen } from "@testing-library/angular"
5+
import userEvent from "@testing-library/user-event"
56
import { of } from "rxjs"
67
import { CodeMapNode, NodeType } from "../../../../codeCharta.model"
78
import { IdToBuildingService } from "../../../../services/idToBuilding/idToBuilding.service"
@@ -11,6 +12,7 @@ import { ThreeRendererService } from "../../../../ui/codeMap/threeViewer/threeRe
1112
import { ThreeSceneService } from "../../../../ui/codeMap/threeViewer/threeSceneService"
1213
import { explorerTreeNodeSelector } from "../../selectors/explorerTreeNode.selector"
1314
import { explorerCountsSelector } from "../../selectors/sidebarExplorer.selectors"
15+
import { ExplorerCollapseService } from "../../services/explorerCollapse.service"
1416
import { SidebarExplorerComponent } from "./sidebarExplorer.component"
1517

1618
const ROOT: CodeMapNode = {
@@ -45,7 +47,7 @@ describe("SidebarExplorerComponent", () => {
4547
})
4648
})
4749

48-
it("should compose header, search bar, sort control and tree", async () => {
50+
it("should compose header, search bar, sort control and tree by default", async () => {
4951
// Arrange & Act
5052
const { container } = await render(SidebarExplorerComponent)
5153

@@ -55,4 +57,38 @@ describe("SidebarExplorerComponent", () => {
5557
expect(container.querySelector("cc-explorer-sort-control")).not.toBe(null)
5658
expect(container.querySelector("cc-explorer-tree")).not.toBe(null)
5759
})
60+
61+
it("should render only the expand button and search bar when collapsed", async () => {
62+
// Arrange
63+
const { container, detectChanges } = await render(SidebarExplorerComponent)
64+
const collapseService = TestBed.inject(ExplorerCollapseService)
65+
66+
// Act
67+
collapseService.toggle()
68+
detectChanges()
69+
70+
// Assert
71+
expect(container.querySelector("cc-explorer-search-bar")).not.toBe(null)
72+
expect(screen.getByTestId("explorer-expand-button")).not.toBe(null)
73+
expect(container.querySelector("cc-explorer-header")).toBe(null)
74+
expect(container.querySelector("cc-explorer-sort-control")).toBe(null)
75+
expect(container.querySelector("cc-explorer-tree")).toBe(null)
76+
})
77+
78+
it("should expand back to the full layout when the expand button is clicked", async () => {
79+
// Arrange
80+
const { container, detectChanges } = await render(SidebarExplorerComponent)
81+
const collapseService = TestBed.inject(ExplorerCollapseService)
82+
collapseService.toggle()
83+
detectChanges()
84+
85+
// Act
86+
await userEvent.click(screen.getByTestId("explorer-expand-button"))
87+
detectChanges()
88+
89+
// Assert
90+
expect(collapseService.isCollapsed()).toBe(false)
91+
expect(container.querySelector("cc-explorer-header")).not.toBe(null)
92+
expect(container.querySelector("cc-explorer-tree")).not.toBe(null)
93+
})
5894
})

visualization/app/codeCharta/features/sidebarExplorer/components/sidebarExplorer/sidebarExplorer.component.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { ChangeDetectionStrategy, Component } from "@angular/core"
1+
import { ChangeDetectionStrategy, Component, inject } from "@angular/core"
2+
import { ExplorerCollapseService } from "../../services/explorerCollapse.service"
23
import { ExplorerHeaderComponent } from "../explorerHeader/explorerHeader.component"
34
import { ExplorerSearchBarComponent } from "../explorerSearchBar/explorerSearchBar.component"
45
import { ExplorerSortControlComponent } from "../explorerSortControl/explorerSortControl.component"
@@ -18,7 +19,17 @@ import { RulesPopoverComponent } from "../rulesPopover/rulesPopover.component"
1819
],
1920
host: {
2021
class: "fixed left-0 w-72 bg-base-100 overflow-hidden flex flex-col shadow-[2px_0_8px_-2px_rgba(0,0,0,0.15)]",
21-
style: "top: var(--cc-bars-height, 98px); height: calc(100vh - var(--cc-bars-height, 98px))"
22+
"[class.rounded-br-md]": "isCollapsed()",
23+
"[style.top]": "'var(--cc-bars-height, 98px)'",
24+
"[style.height]": "isCollapsed() ? 'auto' : 'calc(100vh - var(--cc-bars-height, 98px))'"
2225
}
2326
})
24-
export class SidebarExplorerComponent {}
27+
export class SidebarExplorerComponent {
28+
private readonly collapseService = inject(ExplorerCollapseService)
29+
30+
readonly isCollapsed = this.collapseService.isCollapsed
31+
32+
toggle() {
33+
this.collapseService.toggle()
34+
}
35+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { TestBed } from "@angular/core/testing"
2+
import { ExplorerCollapseService } from "./explorerCollapse.service"
3+
4+
describe("ExplorerCollapseService", () => {
5+
let service: ExplorerCollapseService
6+
7+
beforeEach(() => {
8+
TestBed.configureTestingModule({})
9+
service = TestBed.inject(ExplorerCollapseService)
10+
})
11+
12+
it("should default to expanded (isCollapsed false)", () => {
13+
// Arrange & Act & Assert
14+
expect(service.isCollapsed()).toBe(false)
15+
})
16+
17+
it("should flip isCollapsed to true on toggle()", () => {
18+
// Arrange & Act
19+
service.toggle()
20+
21+
// Assert
22+
expect(service.isCollapsed()).toBe(true)
23+
})
24+
25+
it("should flip isCollapsed back to false on a second toggle()", () => {
26+
// Arrange
27+
service.toggle()
28+
29+
// Act
30+
service.toggle()
31+
32+
// Assert
33+
expect(service.isCollapsed()).toBe(false)
34+
})
35+
})
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Injectable, signal } from "@angular/core"
2+
3+
@Injectable({ providedIn: "root" })
4+
export class ExplorerCollapseService {
5+
readonly isCollapsed = signal(false)
6+
7+
toggle() {
8+
this.isCollapsed.update(value => !value)
9+
}
10+
}

0 commit comments

Comments
 (0)