Skip to content
Closed
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
7 changes: 7 additions & 0 deletions projects/coreui-angular/src/lib/nav/nav-group.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ export class NavGroupComponent {
});
});

readonly #openOnActiveEffect = effect(() => {
const openOnActive = this.#parentNavGroupService?.openOnActive() ?? true;
untracked(() => {
this.#navGroupService.openOnActive.set(openOnActive);
});
});

toggleGroup(event: Event): void {
event.preventDefault();
const next = !this.visibleState();
Expand Down
5 changes: 5 additions & 0 deletions projects/coreui-angular/src/lib/nav/nav-group.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export class NavGroupService {
*/
readonly activeId = signal<string | undefined>(undefined);

/**
* Whether an active nav link opens the branch it sits in. Inherited by nested levels.
*/
readonly openOnActive = signal(true);

/**
* Opens the level owner within its own parent and cascades up to the root.
* Replaced by the owning `c-nav-group`; stays a no-op for the root level.
Expand Down
51 changes: 51 additions & 0 deletions projects/coreui-angular/src/lib/nav/nav-link.directive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, ComponentRef, DebugElement, input } from '@angular/core';
import { By } from '@angular/platform-browser';

import { provideRouter } from '@angular/router';

import { NavGroupComponent } from './nav-group.component';
import { SidebarNavComponent } from '../sidebar/sidebar-nav/sidebar-nav.component';

@Component({
template: '<a cNavLink [active]="active()" [disabled]="disabled()">test</a>',
Expand All @@ -30,6 +33,24 @@ class TestNavGroupComponent {
readonly active = input(false);
}

@Component({
template: `
<c-sidebar-nav dropdownMode="none">
<c-nav-group toggler="group">
<a cNavLink [active]="active()">test</a>
<c-nav-group toggler="nested">
<a cNavLink [active]="nestedActive()">nested</a>
</c-nav-group>
</c-nav-group>
</c-sidebar-nav>
`,
imports: [NavGroupComponent, NavLinkDirective, SidebarNavComponent]
})
class TestSidebarNavDropdownModeComponent {
readonly active = input(false);
readonly nestedActive = input(false);
}

describe('NavLinkDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let component: TestComponent;
Expand Down Expand Up @@ -143,3 +164,33 @@ describe('NavLinkDirective in a nav group', () => {
expect(group.classList.contains('show')).toBe(true);
});
});

describe('NavLinkDirective in a sidebar nav with dropdownMode none', () => {
let fixture: ComponentFixture<TestSidebarNavDropdownModeComponent>;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestSidebarNavDropdownModeComponent],
providers: [provideRouter([])]
}).compileComponents();

fixture = TestBed.createComponent(TestSidebarNavDropdownModeComponent);
fixture.detectChanges();
});

it('should keep the group closed for an active link', () => {
const group = fixture.nativeElement.querySelector('c-nav-group');

fixture.componentRef.setInput('active', true);
fixture.detectChanges();
expect(group.classList.contains('show')).toBe(false);
});

it('should keep a nested group closed for an active link', () => {
const [, nested] = fixture.nativeElement.querySelectorAll('c-nav-group');

fixture.componentRef.setInput('nestedActive', true);
fixture.detectChanges();
expect(nested.classList.contains('show')).toBe(false);
});
});
12 changes: 9 additions & 3 deletions projects/coreui-angular/src/lib/nav/nav-link.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export class NavLinkDirective implements OnDestroy {
readonly #activeEffect = effect(() => {
if (this.active()) {
untracked(() => {
this.#navGroupService?.openBranch();
this.#openBranch();
});
}
});
Expand All @@ -111,6 +111,12 @@ export class NavLinkDirective implements OnDestroy {
this.#classObserver?.disconnect();
}

#openBranch(): void {
if (this.#navGroupService?.openOnActive()) {
this.#navGroupService.openBranch();
}
}

#observeActiveClass(): void {
const host: HTMLElement = this.#hostElement.nativeElement;

Expand All @@ -120,13 +126,13 @@ export class NavLinkDirective implements OnDestroy {

let wasActive = host.classList.contains('active');
if (wasActive) {
this.#navGroupService.openBranch();
this.#openBranch();
}

this.#classObserver = new MutationObserver(() => {
const active = host.classList.contains('active');
if (active && !wasActive) {
this.#navGroupService?.openBranch();
this.#openBranch();
}
wasActive = active;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,72 @@ describe('SidebarNavGroupComponent', () => {
expect(displayOnCollapsing).toBe('block');
});
});

describe('SidebarNavGroupComponent dropdownMode', () => {
let fixture: ComponentFixture<SidebarNavGroupComponent>;
let router: Router;

const item = {
name: 'Tables',
url: '/tables',
children: [{ name: 'Standard Tables', url: '/tables/tables' }]
};

async function createGroup(dropdownMode?: string): Promise<SidebarNavGroupComponent> {
fixture = TestBed.createComponent(SidebarNavGroupComponent);
fixture.componentRef.setInput('item', item);
if (dropdownMode) {
fixture.componentRef.setInput('dropdownMode', dropdownMode);
}
await fixture.whenStable();
fixture.detectChanges();
return fixture.componentInstance;
}

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SidebarNavGroupComponent],
providers: [
provideRouter([
{ path: 'tables', children: [] },
{ path: 'charts', children: [] }
]),
SidebarNavGroupService
]
}).compileComponents();

router = TestBed.inject(Router);
await router.navigate(['/tables']);
});

it('should open under the active route by default', async () => {
const component = await createGroup();
expect(component.open()).toBe(true);
});

it('should stay closed under the active route for none', async () => {
const component = await createGroup('none');
expect(component.open()).toBeFalsy();
});

it('should ignore a later navigation for none', async () => {
const component = await createGroup('none');

fixture.nativeElement.querySelector('.nav-group-toggle').click();
fixture.detectChanges();
expect(component.open()).toBe(true);

await router.navigate(['/charts']);
fixture.detectChanges();
expect(component.open()).toBe(true);
});

it('should close on a non-matching route for path', async () => {
const component = await createGroup('path');
expect(component.open()).toBe(true);

await router.navigate(['/charts']);
fixture.detectChanges();
expect(component.open()).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,33 @@ describe('SidebarNavComponent', () => {
it('should have css classes', () => {
expect(fixture.nativeElement.classList.contains('sidebar-nav')).toBe(true);
});

it('should render the tree variant', () => {
fixture.componentRef.setInput('variant', 'tree');
fixture.detectChanges();
expect(fixture.nativeElement.classList.contains('sidebar-nav-tree')).toBe(true);
});

it('should not render the tree variant as a group item list', () => {
fixture.componentRef.setInput('variant', 'tree');
fixture.componentRef.setInput('groupItems', true);
fixture.detectChanges();
expect(fixture.nativeElement.classList.contains('sidebar-nav-tree')).toBe(false);
expect(fixture.nativeElement.classList.contains('nav-group-items')).toBe(true);
});

it('should compact the whole nav', () => {
fixture.componentRef.setInput('compact', true);
fixture.detectChanges();
expect(fixture.nativeElement.classList.contains('compact')).toBe(true);
});

it('should stop the nav group level opening on active for dropdownMode none', () => {
const navGroupService = fixture.debugElement.injector.get(NavGroupService);
expect(navGroupService.openOnActive()).toBe(true);

fixture.componentRef.setInput('dropdownMode', 'none');
fixture.detectChanges();
expect(navGroupService.openOnActive()).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
booleanAttribute,
Component,
computed,
effect,
ElementRef,
forwardRef,
inject,
Expand All @@ -12,7 +13,8 @@ import {
OnInit,
Renderer2,
signal,
SimpleChanges
SimpleChanges,
untracked
} from '@angular/core';
import { NavigationEnd, Router, RouterModule } from '@angular/router';
import { Observable, Subscription } from 'rxjs';
Expand Down Expand Up @@ -77,7 +79,8 @@ export class SidebarNavGroupComponent implements OnInit, OnDestroy {
* Determines when an inactive `c-sidebar-nav-group` closes.
* - `path`: on an active route change only
* - `close`: when another group is clicked
* - `none`: never, the group stays open
* - `none`: never, the group stays open — it stops reacting to the route altogether,
* so a matching route neither opens it on init nor on a later navigation
*/
readonly dropdownMode = input<'path' | 'none' | 'close'>('path');

Expand Down Expand Up @@ -118,7 +121,7 @@ export class SidebarNavGroupComponent implements OnInit, OnDestroy {
}
});

if (this.samePath(this.#router.routerState.snapshot.url)) {
if (this.dropdownMode() !== 'none' && this.samePath(this.#router.routerState.snapshot.url)) {
this.openGroup(true);
}

Expand Down Expand Up @@ -197,6 +200,7 @@ export class SidebarNavComponent implements OnChanges {
readonly #renderer = inject(Renderer2);
readonly #hostElement = inject(ElementRef);
readonly #sidebarService = inject(SidebarService);
readonly #navGroupService = inject(NavGroupService);

/**
* Configuration object for sidebar-nav.
Expand All @@ -209,7 +213,8 @@ export class SidebarNavComponent implements OnChanges {
* Determines when an inactive `c-sidebar-nav-group` closes.
* - `path`: on an active route change only
* - `close`: when another group is clicked
* - `none`: never, the group stays open
* - `none`: never, the group stays open — it stops reacting to the route altogether,
* so a matching route neither opens it on init nor on a later navigation
*/
readonly dropdownMode = input<'path' | 'none' | 'close'>('path');
/**
Expand All @@ -228,16 +233,31 @@ export class SidebarNavComponent implements OnChanges {
* @default 'navigation'
*/
readonly role = input('navigation');
/**
* Set the sidebar nav variant to tree.
* @default undefined
* @since 5.7.28
*/
readonly variant = input<'tree'>();

readonly hostClasses = computed(() => {
const groupItems = this.groupItems();
const variant = this.variant();
return {
'sidebar-nav': !groupItems,
'nav-group-items': groupItems,
compact: groupItems && this.compact()
[`sidebar-nav-${variant}`]: !groupItems && !!variant,
compact: this.compact()
};
});

readonly #dropdownModeEffect = effect(() => {
const openOnActive = this.dropdownMode() !== 'none';
untracked(() => {
this.#navGroupService.openOnActive.set(openOnActive);
});
});

// @HostBinding('class.nav-group-items')
// get sidebarNavGroupItemsClass(): boolean {
// return !!this.groupItems;
Expand Down
Loading