diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-badge.pipe.spec.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-badge.pipe.spec.ts index af79923d..26fd86eb 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-badge.pipe.spec.ts +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-badge.pipe.spec.ts @@ -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' + ]); + }); }); diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-badge.pipe.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-badge.pipe.ts index 4a57aad7..70a3b918 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-badge.pipe.ts +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-badge.pipe.ts @@ -1,5 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; +import { classMap } from './sidebar-nav.utils'; + @Pipe({ name: 'cSidebarNavBadge' }) @@ -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) }; } } diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-icon.pipe.spec.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-icon.pipe.spec.ts index 2e72e837..bf5f2211 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-icon.pipe.spec.ts +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-icon.pipe.spec.ts @@ -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); diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-icon.pipe.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-icon.pipe.ts index e22c303f..53223bea 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-icon.pipe.ts +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-icon.pipe.ts @@ -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) }; } } diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-label.component.html b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-label.component.html index 1bc6eb93..66461ae2 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-label.component.html +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-label.component.html @@ -3,7 +3,7 @@ [class]="itemClass()" href="{{labelItem.url}}"> @if (helper.hasIcon(labelItem)) { - + } {{ labelItem.name }} @if (helper.hasBadge(labelItem)) { diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-label.component.spec.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-label.component.spec.ts index e93bf3a1..ec3abaf8 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-label.component.spec.ts +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-label.component.spec.ts @@ -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); diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-label.component.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-label.component.ts index 39c93e55..42c202b4 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-label.component.ts +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-label.component.ts @@ -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', @@ -20,25 +21,20 @@ export class SidebarNavLabelComponent { */ readonly item = input({}); - readonly itemClass = computed(() => { - const classes: Record = { - '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 = 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) + }; }); } diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-title.component.spec.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-title.component.spec.ts index 310e9efa..b0c355ef 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-title.component.spec.ts +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-title.component.spec.ts @@ -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); }); }); diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-title.component.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-title.component.ts index aade91c5..e214156c 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-title.component.ts +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav-title.component.ts @@ -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); @@ -15,17 +19,20 @@ export class SidebarNavTitleComponent { */ readonly item = input(); + 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); diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.component.html b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.component.html index 1abdfeac..803ac841 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.component.html +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.component.html @@ -19,7 +19,6 @@ } @case ('label') { diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.component.spec.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.component.spec.ts index 90d5febf..8c2fb507 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.component.spec.ts +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.component.spec.ts @@ -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', ''] }, 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 } }, {} @@ -122,7 +128,11 @@ 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); }); @@ -130,7 +140,10 @@ describe('SidebarNavComponent items', () => { 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'); }); @@ -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'); @@ -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']); + }); +}); diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.service.spec.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.service.spec.ts index 892c3819..11d283d8 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.service.spec.ts +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.service.spec.ts @@ -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 }); + }); }); diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.service.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.service.ts index f1d9fcbe..e7721807 100644 --- a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.service.ts +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.service.ts @@ -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 { @@ -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; } } diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.utils.spec.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.utils.spec.ts new file mode 100644 index 00000000..0e0b57af --- /dev/null +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.utils.spec.ts @@ -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({}); + }); +}); diff --git a/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.utils.ts b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.utils.ts new file mode 100644 index 00000000..93b85e85 --- /dev/null +++ b/projects/coreui-angular/src/lib/sidebar/sidebar-nav/sidebar-nav.utils.ts @@ -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 => + Object.fromEntries( + (classes ?? '') + .split(/[\t\n\f\r ]+/) + .filter(Boolean) + .map((name) => [name, true]) + );