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
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,17 @@ describe('SidebarNavBadgePipe', () => {
const pipe = new SidebarNavBadgePipe();
expect(pipe).toBeTruthy();
});

it('should add each class of the badge', () => {
const pipe = new SidebarNavBadgePipe();
const classes = pipe.transform({ badge: { color: 'info', class: 'first second' } });
expect(Object.keys(classes).filter((key) => classes[key])).toEqual([
'badge',
'ms-auto',
'badge-sm',
'bg-info',
'first',
'second'
]);
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Pipe, PipeTransform } from '@angular/core';

import { classMap } from './sidebar-nav.utils';

@Pipe({
name: 'cSidebarNavBadge'
})
Expand All @@ -12,7 +14,7 @@ export class SidebarNavBadgePipe implements PipeTransform {
'badge-sm': !badge.size,
[`badge-${badge.size}`]: !!badge.size,
[`bg-${badge.color}`]: !!badge.color,
[`${badge.class}`]: !!badge.class
...classMap(badge.class)
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ describe('SidebarNavIconPipe', () => {
expect(pipe.transform({ icon: 'cil-list' })).toEqual({ 'nav-icon': true, 'cil-list': true });
});

it('should add each class of the item icon', () => {
const pipe = new SidebarNavIconPipe();
expect(pipe.transform({ icon: 'fa fa-home' })).toEqual({ 'nav-icon': true, fa: true, 'fa-home': true });
});

it('should return the nav-icon class without an item', () => {
const pipe = new SidebarNavIconPipe();
const classes = pipe.transform(undefined);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Pipe, PipeTransform } from '@angular/core';

import { classMap } from './sidebar-nav.utils';

@Pipe({
name: 'cSidebarNavIcon'
})
export class SidebarNavIconPipe implements PipeTransform {
transform(item: any, args?: any): any {
const icon = item?.icon;
return {
'nav-icon': true,
[`${icon}`]: !!icon
...classMap(item?.icon)
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[class]="itemClass()"
href="{{labelItem.url}}">
@if (helper.hasIcon(labelItem)) {
<i [class]="labelIconClass()"></i>
<i [class]="labelIconClass()" aria-hidden="true"></i>
}
<ng-container>{{ labelItem.name }}</ng-container>
@if (helper.hasBadge(labelItem)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,36 @@ describe('SidebarNavLabelComponent', () => {
it('should set correct itemClass', async () => {
const link = fixture.debugElement.query(By.css('a')).nativeElement;

expect(link.classList.contains('nav-link')).toBe(true);
expect(link.classList.contains('nav-label')).toBe(true);
expect(link.classList.contains('c-nav-label')).toBe(true);
expect(link.classList.contains('c-active')).toBe(true);
expect(link.classList.contains('active')).toBe(false);
item.class = 'custom-class';
fixture.componentRef.setInput('item', { ...item });
await fixture.whenStable();
expect(link.classList.contains('custom-class')).toBe(true);
});

it('should set several classes of the item', async () => {
fixture.componentRef.setInput('item', {
...item,
class: 'first second',
icon: 'fa fa-star',
label: { class: 'third fourth' }
});
await fixture.whenStable();
const link = fixture.debugElement.query(By.css('a')).nativeElement;
expect(link.classList.contains('first')).toBe(true);
expect(link.classList.contains('second')).toBe(true);
const icon = fixture.debugElement.query(By.css('i')).nativeElement;
expect(icon.classList.contains('third')).toBe(true);
expect(icon.classList.contains('fourth')).toBe(true);
expect(icon.classList.contains('fa')).toBe(true);
expect(icon.classList.contains('fa-star')).toBe(true);
expect(icon.getAttribute('aria-hidden')).toBe('true');
});

it('should display label name', () => {
const label = fixture.debugElement.query(By.css('a')).nativeElement;
expect(label.textContent).toBe(item.name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HtmlAttributesDirective } from '../../shared';
import { SidebarNavHelper } from './sidebar-nav.service';
import { SidebarNavBadgePipe } from './sidebar-nav-badge.pipe';
import { INavData } from './sidebar-nav';
import { classMap } from './sidebar-nav.utils';

@Component({
selector: 'c-sidebar-nav-label',
Expand All @@ -20,25 +21,20 @@ export class SidebarNavLabelComponent {
*/
readonly item = input<INavData>({});

readonly itemClass = computed(() => {
const classes: Record<string, boolean> = {
'c-nav-label': true,
'c-active': true
};
const itemClass = this.item().class;
if (itemClass) {
classes[itemClass] = !!itemClass;
}
return classes;
});
readonly itemClass = computed(() => ({
'nav-link': true,
'nav-label': true,
'c-nav-label': true,
'c-active': true,
...classMap(this.item().class)
}));

readonly labelIconClass = computed(() => {
const item = this.item();
const iconClasses: Record<string, boolean> = this.helper.getIconClass(item);
const variant = `text-${item.label?.variant}`;
iconClasses[variant] = !!item.label?.variant;
const labelClass = item.label?.class ?? '';
iconClasses[labelClass] = !!labelClass;
return iconClasses;
return {
...this.helper.getIconClass(item),
[`text-${item.label?.variant}`]: !!item.label?.variant,
...classMap(item.label?.class)
};
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,35 @@ describe('SidebarNavTitleComponent content', () => {
expect(wrapper.getAttribute('data-test')).toBe('x');
});

it('should render the name with several classes of the item', async () => {
const title = await render({ title: true, name: 'Theme', class: 'first second' });
expect(title.textContent).toBe('Theme');
expect([...title.classList].sort()).toEqual(['first', 'nav-title', 'second']);
});

it('should render nothing without a name', async () => {
const title = await render({ title: true });
expect(title.childNodes.length).toBe(0);
expect([...title.classList]).toEqual(['nav-title']);
});

it('should replace the name and the class when the item changes', async () => {
const fixture = TestBed.createComponent(SidebarNavTitleComponent);
fixture.componentRef.setInput('item', { title: true, name: 'One', class: 'first' });
await fixture.whenStable();
fixture.componentRef.setInput('item', { title: true, name: 'Two', class: 'second' });
await fixture.whenStable();
const title = fixture.nativeElement as HTMLElement;
expect(title.textContent).toBe('Two');
expect([...title.classList].sort()).toEqual(['nav-title', 'second']);
});

it('should clear the name when the item loses it', async () => {
const fixture = TestBed.createComponent(SidebarNavTitleComponent);
fixture.componentRef.setInput('item', { title: true, name: 'One', wrapper: { element: 'span' } });
await fixture.whenStable();
fixture.componentRef.setInput('item', { title: true });
await fixture.whenStable();
expect((fixture.nativeElement as HTMLElement).childNodes.length).toBe(0);
});
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Component, effect, ElementRef, inject, input, Renderer2 } from '@angular/core';
import { Component, computed, effect, ElementRef, inject, input, Renderer2 } from '@angular/core';
import { INavData } from './sidebar-nav';
import { classMap } from './sidebar-nav.utils';

@Component({
selector: 'c-sidebar-nav-title',
template: ''
template: '',
host: {
'[class]': 'itemClass()'
}
})
export class SidebarNavTitleComponent {
readonly #elementRef = inject(ElementRef);
Expand All @@ -15,17 +19,20 @@ export class SidebarNavTitleComponent {
*/
readonly item = input<INavData>();

readonly itemClass = computed(() => ({
'nav-title': true,
...classMap(this.item()?.class)
}));

readonly #itemEffect = effect(() => {
const item = this.item();
const nativeElement: HTMLElement = this.#elementRef.nativeElement;
for (const child of [...nativeElement.childNodes]) {
this.#renderer.removeChild(nativeElement, child);
}
if (item?.name) {
const nativeElement: HTMLElement = this.#elementRef.nativeElement;
const name = this.#renderer.createText(item.name);

if (item?.class) {
const classes = item.class;
this.#renderer.addClass(nativeElement, classes);
}

if (item?.wrapper) {
const wrapper = this.#renderer.createElement(item.wrapper.element);
this.addAttribs(item.wrapper.attributes, wrapper);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
<c-sidebar-nav-title
[cHtmlAttr]="nItem.attributes ?? {}"
[item]="nItem"
[class]="nItem | cSidebarNavItemClass"
/>
}
@case ('label') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,22 @@ describe('SidebarNavComponent items', () => {
let nav: HTMLElement;

const navItems: INavData[] = [
{ title: true, name: 'Components', class: 'text-primary' },
{ title: true, name: 'Components', class: 'text-primary small' },
{ divider: true },
{ name: 'Label', url: '/label', label: { variant: 'info' }, icon: 'cil-star' },
{
name: 'Base',
url: '/base',
icon: 'fa fa-home',
iconComponent: { content: ['64 64', '<circle cx="32" cy="32" r="16"/>'] },
children: [{ name: 'Accordion', url: '/base/accordion' }]
},
{ name: 'Dashboard', url: '/dashboard', icon: 'cil-speedometer', badge: { color: 'info', text: 'NEW' } },
{
name: 'Dashboard',
url: '/dashboard',
icon: 'cil-speedometer',
badge: { color: 'info', text: 'NEW', class: 'first second' }
},
{ name: 'CoreUI', url: 'https://coreui.io', attributes: { target: '_blank' } },
{ name: 'Disabled', url: '/disabled', attributes: { disabled: true } },
{}
Expand All @@ -122,15 +128,22 @@ describe('SidebarNavComponent items', () => {
'c-sidebar-nav-link'
]);
expect(nav.querySelector('c-sidebar-nav-title')?.textContent).toBe('Components');
expect(nav.querySelector('c-sidebar-nav-title')?.classList.contains('nav-title')).toBe(true);
expect([...(nav.querySelector('c-sidebar-nav-title')?.classList ?? [])].sort()).toEqual([
'nav-title',
'small',
'text-primary'
]);
expect(nav.querySelector('c-sidebar-nav-label a')?.textContent?.trim()).toBe('Label');
expect(nav.querySelector('c-sidebar-nav-label i')?.classList.contains('text-info')).toBe(true);
});

it('should render a group with its icon and children', () => {
const group = nav.querySelector('c-sidebar-nav-group') as HTMLElement;
expect(group.querySelector('.nav-group-toggle')?.textContent).toContain('Base');
expect(group.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 64 64');
const icon = group.querySelector('svg') as SVGElement;
expect(icon.getAttribute('viewBox')).toBe('0 0 64 64');
expect(icon.classList.contains('fa')).toBe(true);
expect(icon.classList.contains('fa-home')).toBe(true);
expect(group.querySelector('c-sidebar-nav-link a')?.getAttribute('href')).toBe('/base/accordion');
});

Expand All @@ -142,6 +155,8 @@ describe('SidebarNavComponent items', () => {
const badge = internal.querySelector('.badge') as HTMLElement;
expect(badge.textContent).toBe('NEW');
expect(badge.classList.contains('bg-info')).toBe(true);
expect(badge.classList.contains('first')).toBe(true);
expect(badge.classList.contains('second')).toBe(true);

expect(external.getAttribute('href')).toBe('https://coreui.io');
expect(external.getAttribute('target')).toBe('_blank');
Expand All @@ -150,3 +165,20 @@ describe('SidebarNavComponent items', () => {
expect([...disabled.classList].sort()).toEqual(['btn-link', 'disabled', 'nav-link']);
});
});

describe('SidebarNavComponent title', () => {
it('should take the title name and class from the same item when it is mutated in place', async () => {
TestBed.configureTestingModule({ providers: [provideRouter([])] });
const navItems: INavData[] = [{ title: true, name: 'Theme', class: 'first' }];
const fixture = TestBed.createComponent(SidebarNavComponent);
fixture.componentRef.setInput('navItems', navItems);
await fixture.whenStable();
navItems[0].class = 'second';
navItems[0].name = 'Renamed';
fixture.componentRef.setInput('navItems', [...navItems]);
await fixture.whenStable();
const title = fixture.nativeElement.querySelector('c-sidebar-nav-title') as HTMLElement;
expect(title.textContent).toBe('Theme');
expect([...title.classList].sort()).toEqual(['first', 'nav-title']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,8 @@ describe('SidebarNavHelper', () => {
expect(service.getIconClass({ icon: 'icon-ban' })).toEqual(expect.objectContaining({ 'nav-icon': true }));
expect(service.getIconClass({ icon: '' })).toEqual(expect.objectContaining({ 'nav-icon': true }));
});

it('should return each class of the icon', () => {
expect(service.getIconClass({ icon: 'fa fa-ban' })).toEqual({ 'nav-icon': true, fa: true, 'fa-ban': true });
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';

import { INavData } from './sidebar-nav';
import { classMap } from './sidebar-nav.utils';

@Injectable()
export abstract class SidebarNavService {
Expand Down Expand Up @@ -43,12 +44,9 @@ export class SidebarNavHelper {
public hasIconComponent = (item: INavData) => Boolean(item?.iconComponent);

public getIconClass(item: INavData): any {
const classes = {
'nav-icon': true
return {
'nav-icon': true,
...classMap(item.icon)
};
const icon = item.icon;
// @ts-ignore
classes[icon] = this.hasIcon(item);
return classes;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { classMap } from './sidebar-nav.utils';

describe('classMap', () => {
it('should map each class of the string', () => {
expect(classMap(' first second ')).toEqual({ first: true, second: true });
});

it('should split on ASCII whitespace only, like the class attribute', () => {
expect(classMap('first\tsecond\nthird\u00a0fourth')).toEqual({
first: true,
second: true,
'third\u00a0fourth': true
});
});

it('should return an empty map without classes', () => {
expect(classMap(undefined)).toEqual({});
expect(classMap('')).toEqual({});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Maps a whitespace-separated class string to a `[class]` binding object, one key per class.
* @param classes the classes, e.g. `item.class` of a nav item
* @returns a key set to `true` for each class, empty without classes
*/
export const classMap = (classes?: string): Record<string, boolean> =>
Object.fromEntries(
(classes ?? '')
.split(/[\t\n\f\r ]+/)
.filter(Boolean)
.map((name) => [name, true])
);
Loading