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
3 changes: 3 additions & 0 deletions projects/ppwcode/ng-wireframe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ The following CSS variables are available for theming. Just add them to the `bod
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--ppw-sidenav-close-button-color` | |
| `--ppw-sidenav-navigation-item-child-margin` | The default margin to apply to children have a collapsible navigation item. |
| `--ppw-sidenav-navigation-item-border` | The border applied to navigation items. |
| `--ppw-sidenav-navigation-item-active-background-color` | The background color for the active navigation item. By default, this reuses the hover background color. |
| `--ppw-sidenav-navigation-item-active-border` | The border applied to the active navigation item. By default, this reuses the regular navigation item border. |
| `--ppw-sidenav-navigation-item-hover-background-color` | |
| `--ppw-sidenav-navigation-item-icon-color` | |
| `--ppw-sidenav-navigation-item-icon-font-size` | |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<ng-content select="[ppw-sidebar-top]"></ng-content>
</div>
<div class="ppw-sidenav-navigation-wrapper flex-column">
@for (navigationItem of navigationItems(); track trackNavigationItem(navigationItem)) {
@for (navigationItem of navigationItemsWithActiveState(); track trackNavigationItem(navigationItem)) {
<ng-container *ngTemplateOutlet="navigationItemTemplate; context: { navigationItem }"></ng-container>
}
</div>
Expand All @@ -30,6 +30,7 @@
type="button"
class="ppw-sidenav-navigation-item ppw-sidenav-navigation-item-level-{{ level ?? 1 }}"
[class.navigation-item-disabled]="navigationItem.isEnabled === false"
[class.ppw-sidenav-navigation-item-active]="navigationItem.isActive"
(click)="onClickNavigationItem(navigationItem)"
>
<span class="ppw-sidenav-navigation-item-link">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
align-items: center;
justify-content: space-between;
margin: var(--ppw-sidenav-navigation-item-margin, 4px);
border: none;
border: var(--ppw-sidenav-navigation-item-border, none);
border-radius: var(--ppw-sidenav-navigation-item-radius, 8px);

.ppw-sidenav-navigation-item-text {
Expand Down Expand Up @@ -98,6 +98,14 @@
cursor: pointer;
}

&.ppw-sidenav-navigation-item-active {
background: var(
--ppw-sidenav-navigation-item-active-background-color,
var(--ppw-sidenav-navigation-item-hover-background-color, rgba(255, 255, 255, 0.15))
);
border: var(--ppw-sidenav-navigation-item-active-border, var(--ppw-sidenav-navigation-item-border, none));
}

$defaultLeftMargin: 12px;
&.ppw-sidenav-navigation-item-level-1 {
margin-left: calc(var(--ppw-sidenav-navigation-item-child-margin-left, #{$defaultLeftMargin}) * 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ describe('Left sidenav component', () => {
fixture.detectChanges()
})

function getNavigationItemButton(label: string): HTMLButtonElement {
const navigationItemButtons: Array<HTMLButtonElement> = Array.from(
fixture.nativeElement.querySelectorAll('.ppw-sidenav-navigation-item')
)
const navigationItemButton = navigationItemButtons.find((button) => button.textContent?.includes(label))

if (!navigationItemButton) {
throw new Error(`Could not find navigation item button with label "${label}".`)
}

return navigationItemButton
}

it('should do nothing when clicking disabled navigation items', () => {
expect(component.navigationItemIsOpened(disabledNavigationItem)).toBe(false)

Expand Down Expand Up @@ -105,6 +118,36 @@ describe('Left sidenav component', () => {
expect(component.navigationItemIsOpened(childrenNavigationItem)).toBe(false)
})

it('should mark the active navigation item', () => {
vi.spyOn(router, 'isActive').mockImplementation((url) => url === '/item-2')

fixture.componentRef.setInput('navigationItems', [...navigationItems])
fixture.detectChanges()

expect(getNavigationItemButton('Navigation item 2').classList).toContain('ppw-sidenav-navigation-item-active')
expect(getNavigationItemButton('Navigation item 3').classList).not.toContain(
'ppw-sidenav-navigation-item-active'
)
})

it('should keep parents with an active child opened', () => {
vi.spyOn(router, 'isActive').mockImplementation((url) => url === '/item-2')

fixture.componentRef.setInput('navigationItems', [...navigationItems])
fixture.detectChanges()

expect(getNavigationItemButton('Navigation item 2')).toBeTruthy()
})

it('should not call the active route helper when unrelated template state changes', () => {
const navigationItemIsActiveSpy = vi.spyOn(component, 'navigationItemIsActive')

fixture.componentRef.setInput('showMenuCloseButton', false)
fixture.detectChanges()

expect(navigationItemIsActiveSpy).not.toHaveBeenCalled()
})

it('should throw when an external link navigation item contains children', () => {
const navigationItemsWithExternalLink: Array<NavigationItem> = [
...navigationItems,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CommonModule, NgOptimizedImage } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
InputSignal,
Expand All @@ -12,10 +13,18 @@ import {
} from '@angular/core'
import { MatIconModule } from '@angular/material/icon'
import { MatListModule } from '@angular/material/list'
import { Router } from '@angular/router'
import { toSignal } from '@angular/core/rxjs-interop'
import { NavigationEnd, Router } from '@angular/router'
import { TranslatePipe } from '@ngx-translate/core'
import { filter, map, startWith } from 'rxjs'
import { NavigationItem } from '../navigation-item/navigation-item.model'

interface NavigationItemWithActiveState extends NavigationItem {
children?: Array<NavigationItemWithActiveState>
isActive: boolean
hasActiveChild: boolean
}

@Component({
selector: 'ppw-left-sidenav',
imports: [CommonModule, MatIconModule, MatListModule, TranslatePipe, NgOptimizedImage],
Expand All @@ -37,16 +46,46 @@ export class LeftSidenavComponent implements OnChanges {
public navigated: OutputEmitterRef<NavigationItem> = output()

private _router: Router = inject(Router)
private _currentUrl = toSignal(
this._router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
map((event) => event.urlAfterRedirects),
startWith(this._router.url)
)
)
private _openedNavigationItems: Array<string> = []

protected navigationItemsWithActiveState = computed<Array<NavigationItemWithActiveState>>(() =>
this.addActiveStateToNavigationItems(this.navigationItems() ?? [], this._currentUrl())
)

public ngOnChanges(changes: SimpleChanges): void {
if (changes['navigationItems']) {
this.validateNavigationItems(this.navigationItems())
}
}

public navigationItemIsOpened(navigationItem: NavigationItem): boolean {
return this._openedNavigationItems.includes(JSON.stringify(navigationItem))
return (
this._openedNavigationItems.includes(this.getNavigationItemKey(navigationItem)) ||
this.navigationItemHasActiveChild(navigationItem)
)
}

public navigationItemIsActive(
navigationItem: NavigationItem,
currentUrl: string | undefined = this._currentUrl()
): boolean {
if (!currentUrl || navigationItem.isExternalLink || !navigationItem.fullRouterPath) {
return false
}

return this._router.isActive(navigationItem.fullRouterPath, {
paths: 'exact',
queryParams: 'ignored',
matrixParams: 'ignored',
fragment: 'ignored'
})
}

public onClickNavigationItem(navigationItem: NavigationItem): void {
Expand All @@ -68,7 +107,7 @@ export class LeftSidenavComponent implements OnChanges {
}

protected trackNavigationItem(navigationItem: NavigationItem): string {
return JSON.stringify(navigationItem)
return this.getNavigationItemKey(navigationItem)
}

private toggleNavigationItemOpened(navigationItem: NavigationItem): void {
Expand All @@ -80,11 +119,13 @@ export class LeftSidenavComponent implements OnChanges {
}

private closeNavigationItem(navigationItem: NavigationItem): void {
this._openedNavigationItems = this._openedNavigationItems.filter((ni) => ni !== JSON.stringify(navigationItem))
this._openedNavigationItems = this._openedNavigationItems.filter(
(ni) => ni !== this.getNavigationItemKey(navigationItem)
)
}

private openNavigationItem(navigationItem: NavigationItem): void {
this._openedNavigationItems.push(JSON.stringify(navigationItem))
this._openedNavigationItems.push(this.getNavigationItemKey(navigationItem))
}

private validateNavigationItems(navigationItems: Array<NavigationItem> | null): void {
Expand All @@ -97,4 +138,65 @@ export class LeftSidenavComponent implements OnChanges {
this.validateNavigationItems(item.children ?? [])
})
}

private addActiveStateToNavigationItems(
navigationItems: Array<NavigationItem>,
currentUrl: string | undefined
): Array<NavigationItemWithActiveState> {
return navigationItems.map((navigationItem) => this.addActiveStateToNavigationItem(navigationItem, currentUrl))
}

private addActiveStateToNavigationItem(
navigationItem: NavigationItem,
currentUrl: string | undefined
): NavigationItemWithActiveState {
const children = navigationItem.children?.map((childNavigationItem) =>
this.addActiveStateToNavigationItem(childNavigationItem, currentUrl)
)
const isActive = this.navigationItemIsActive(navigationItem, currentUrl)

return {
...navigationItem,
children,
isActive,
hasActiveChild:
children?.some(
(childNavigationItem) => childNavigationItem.isActive || childNavigationItem.hasActiveChild
) ?? false
}
}

private navigationItemHasActiveChild(navigationItem: NavigationItem): boolean {
if (this.navigationItemHasActiveState(navigationItem)) {
return navigationItem.hasActiveChild
}

return (
navigationItem.children?.some((childNavigationItem) => {
return (
this.navigationItemIsActive(childNavigationItem) ||
this.navigationItemHasActiveChild(childNavigationItem)
)
}) ?? false
)
}

private navigationItemHasActiveState(
navigationItem: NavigationItem
): navigationItem is NavigationItemWithActiveState {
return 'isActive' in navigationItem && 'hasActiveChild' in navigationItem
}

private getNavigationItemKey(navigationItem: NavigationItem): string {
return JSON.stringify({
label: navigationItem.label,
icon: navigationItem.icon,
fullRouterPath: navigationItem.fullRouterPath,
isEnabled: navigationItem.isEnabled,
isExternalLink: navigationItem.isExternalLink,
children: navigationItem.children?.map((childNavigationItem) =>
this.getNavigationItemKey(childNavigationItem)
)
})
}
}
Loading