diff --git a/api-generator/api-generator.js b/api-generator/api-generator.js index 35ca2301..cb12ff2d 100644 --- a/api-generator/api-generator.js +++ b/api-generator/api-generator.js @@ -724,7 +724,13 @@ async function main() { } for (const key in mergedDocs) { - const typedocJSON = JSON.stringify(mergedDocs[key], null, 4); + const doc = mergedDocs[key]; + const isEmpty = + Object.keys(doc).length === 1 && + doc.components && + Object.keys(doc.components).length === 0; + if (isEmpty) continue; + const typedocJSON = JSON.stringify(doc, null, 4); !fs.existsSync(outputPath) && fs.mkdirSync(outputPath); fs.writeFileSync(path.resolve(outputPath, `${key}.json`), typedocJSON); } @@ -814,7 +820,8 @@ const getTypesValue = (typeobj) => { // Example: { name: string; age: number } -> { "name": "string", "age": "number" } if (Array.isArray(children) && children.length) { const entries = children.map((ch) => ({ - [ch.name]: ch.type?.toString?.() ?? 'unknown' + [ch.flags?.isOptional ? ch.name + '?' : ch.name]: + ch.type?.toString?.() ?? 'unknown' })); return JSON.stringify(Object.assign({}, ...entries), null, 4); } @@ -824,7 +831,8 @@ const getTypesValue = (typeobj) => { // Example: { name: string; age: number } -> { "name": "string", "age": "number" } if (type?.type === 'reflection' && type.declaration?.children?.length) { const entries = type.declaration.children.map((ch) => ({ - [ch.name]: ch.type?.toString?.() ?? 'unknown' + [ch.flags?.isOptional ? ch.name + '?' : ch.name]: + ch.type?.toString?.() ?? 'unknown' })); return JSON.stringify(Object.assign({}, ...entries), null, 4); } diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index 26acac61..bf456a5f 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -110,11 +110,11 @@ const components: ComponentEntry[] = [ // await page.locator('cps-select').first().click(); // } // }, - // { - // route: '/sidebar-menu', - // name: 'Sidebar menu', - // selector: 'cps-sidebar-menu' - // }, + { + route: '/sidebar-menu', + name: 'Sidebar menu', + selector: 'cps-sidebar-menu' + }, // { route: '/switch', name: 'Switch', selector: 'cps-switch' }, // { route: '/tab-group', name: 'Tab group', selector: 'cps-tab-group' }, // { route: '/table', name: 'Table', selector: 'cps-table' }, diff --git a/projects/composition/src/app/api-data/cps-button-toggle.json b/projects/composition/src/app/api-data/cps-button-toggle.json index 2ce9cac9..5b999a1c 100644 --- a/projects/composition/src/app/api-data/cps-button-toggle.json +++ b/projects/composition/src/app/api-data/cps-button-toggle.json @@ -141,7 +141,7 @@ "values": [ { "name": "CpsButtonToggleOption", - "value": "{\n \"value\": \"any\",\n \"label\": \"string\",\n \"ariaLabel\": \"string\",\n \"icon\": \"string\",\n \"disabled\": \"boolean\",\n \"tooltip\": \"string\"\n}", + "value": "{\n \"value\": \"any\",\n \"label?\": \"string\",\n \"ariaLabel?\": \"string\",\n \"icon?\": \"string\",\n \"disabled?\": \"boolean\",\n \"tooltip?\": \"string\"\n}", "description": "CpsButtonToggleOption is used to define the options of the CpsButtonToggleComponent." } ] diff --git a/projects/composition/src/app/api-data/cps-menu.json b/projects/composition/src/app/api-data/cps-menu.json index d77542a1..e06fbc2e 100644 --- a/projects/composition/src/app/api-data/cps-menu.json +++ b/projects/composition/src/app/api-data/cps-menu.json @@ -149,7 +149,7 @@ "values": [ { "name": "CpsMenuItem", - "value": "{\n \"title\": \"string\",\n \"action\": \"(event?: any) => void\",\n \"icon\": \"string\",\n \"desc\": \"string\",\n \"url\": \"string\",\n \"target\": \"string\",\n \"disabled\": \"boolean\",\n \"loading\": \"boolean\"\n}", + "value": "{\n \"title?\": \"string\",\n \"ariaLabel?\": \"string\",\n \"action?\": \"(event?: any) => void\",\n \"icon?\": \"string\",\n \"desc?\": \"string\",\n \"url?\": \"string\",\n \"target?\": \"string\",\n \"disabled?\": \"boolean\",\n \"loading?\": \"boolean\"\n}", "description": "CpsMenuItem is used to define the items of the CpsMenuComponent." }, { diff --git a/projects/composition/src/app/api-data/cps-radio-group.json b/projects/composition/src/app/api-data/cps-radio-group.json index 4585e63f..0cba86b3 100644 --- a/projects/composition/src/app/api-data/cps-radio-group.json +++ b/projects/composition/src/app/api-data/cps-radio-group.json @@ -241,7 +241,7 @@ "values": [ { "name": "CpsRadioOption", - "value": "{\n \"value\": \"any\",\n \"label\": \"string\",\n \"ariaLabel\": \"string\",\n \"disabled\": \"boolean\",\n \"tooltip\": \"string\"\n}", + "value": "{\n \"value\": \"any\",\n \"label?\": \"string\",\n \"ariaLabel?\": \"string\",\n \"disabled?\": \"boolean\",\n \"tooltip?\": \"string\"\n}", "description": "CpsRadioOption is used to define the options of the CpsRadioGroupComponent." } ] diff --git a/projects/composition/src/app/api-data/cps-sidebar-menu.json b/projects/composition/src/app/api-data/cps-sidebar-menu.json index c4670ca9..46c60d5e 100644 --- a/projects/composition/src/app/api-data/cps-sidebar-menu.json +++ b/projects/composition/src/app/api-data/cps-sidebar-menu.json @@ -29,6 +29,14 @@ "default": "false", "description": "Determines whether the menu items should allow activating only exact links." }, + { + "name": "ariaLabel", + "optional": false, + "readonly": false, + "type": "string", + "default": "Main navigation", + "description": "Aria label for the sidebar, used for accessibility." + }, { "name": "height", "optional": false, @@ -46,7 +54,7 @@ "values": [ { "name": "CpsSidebarMenuItem", - "value": "{\n \"title\": \"string\",\n \"icon\": \"string\",\n \"url\": \"string\",\n \"target\": \"string\",\n \"disabled\": \"boolean\",\n \"items\": \"CpsMenuItem[]\"\n}", + "value": "{\n \"title\": \"string\",\n \"icon\": \"string\",\n \"url?\": \"string\",\n \"target?\": \"string\",\n \"disabled?\": \"boolean\",\n \"items?\": \"CpsMenuItem[]\"\n}", "description": "CpsSidebarMenuItem is used to define the items of the CpsSidebarMenuComponent." } ] diff --git a/projects/composition/src/app/api-data/cps-table.json b/projects/composition/src/app/api-data/cps-table.json index 4d4ff44d..1177bc61 100644 --- a/projects/composition/src/app/api-data/cps-table.json +++ b/projects/composition/src/app/api-data/cps-table.json @@ -744,7 +744,7 @@ "values": [ { "name": "CpsColumnFilterCategoryOption", - "value": "{\n \"value\": \"any\",\n \"label\": \"string\",\n \"icon\": \"string\",\n \"disabled\": \"boolean\",\n \"tooltip\": \"string\"\n}", + "value": "{\n \"value\": \"any\",\n \"label?\": \"string\",\n \"icon?\": \"string\",\n \"disabled?\": \"boolean\",\n \"tooltip?\": \"string\"\n}", "description": "CpsColumnFilterCategoryOption is used to define the options of the CpsColumnFilterCategoryComponent." }, { diff --git a/projects/composition/src/app/pages/menu-page/menu-page.component.ts b/projects/composition/src/app/pages/menu-page/menu-page.component.ts index 0de3ae02..c589336c 100644 --- a/projects/composition/src/app/pages/menu-page/menu-page.component.ts +++ b/projects/composition/src/app/pages/menu-page/menu-page.component.ts @@ -50,6 +50,7 @@ export class MenuPageComponent { } }, { + ariaLabel: 'Sixth item is loading', loading: true }, { @@ -95,6 +96,7 @@ export class MenuPageComponent { } }, { + ariaLabel: 'Sixth item is loading', loading: true }, { diff --git a/projects/composition/src/app/pages/sidebar-menu-page/sidebar-menu-page.component.scss b/projects/composition/src/app/pages/sidebar-menu-page/sidebar-menu-page.component.scss index 28d5f19a..bed08061 100644 --- a/projects/composition/src/app/pages/sidebar-menu-page/sidebar-menu-page.component.scss +++ b/projects/composition/src/app/pages/sidebar-menu-page/sidebar-menu-page.component.scss @@ -3,12 +3,12 @@ app-component-docs-viewer { height: 100%; } - padding: 0 24px 0 24px; + padding: 0 1.5rem 0 1.5rem; .desc { - margin-top: 24px; + margin-top: 1.5rem; color: var(--cps-color-text-dark); - margin-left: 24px; - font-size: 18px; + margin-left: 1.5rem; + font-size: 1.125rem; } ::ng-deep { .example-content { diff --git a/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.html b/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.html index 8fe2df64..6af8d051 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.html @@ -39,6 +39,7 @@ role="menuitem" [class]="itemsClasses[i]" tabindex="-1" + [attr.aria-label]="item.ariaLabel || null" [attr.aria-disabled]=" item.disabled || item.loading ? true : null " @@ -58,6 +59,7 @@ role="menuitem" [class]="itemsClasses[i]" tabindex="-1" + [attr.aria-label]="item.ariaLabel || null" [attr.aria-disabled]=" item.disabled || item.loading ? true : null " @@ -82,6 +84,7 @@ role="menuitem" [class]="itemsClasses[i]" tabindex="-1" + [attr.aria-label]="item.ariaLabel || null" [attr.aria-disabled]=" item.disabled || item.loading ? true : null " diff --git a/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.ts b/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.ts index 2cd822ec..ed2904d4 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.ts @@ -26,7 +26,8 @@ import { SimpleChanges, ViewChild, ViewEncapsulation, - ViewRef + ViewRef, + inject } from '@angular/core'; import { OverlayService, SharedModule } from 'primeng/api'; import { ConnectedOverlayScrollHandler, DomHandler } from 'primeng/dom'; @@ -35,6 +36,7 @@ import { Subscription } from 'rxjs'; import { CpsIconComponent } from '../cps-icon/cps-icon.component'; import { CpsProgressCircularComponent } from '../cps-progress-circular/cps-progress-circular.component'; import { PrimeNG } from 'primeng/config'; +import { CPS_INPUT_MODALITY_SERVICE } from '../../services/cps-input-modality/cps-input-modality.service'; type Nullable = T | null | undefined; type VoidListener = () => void | null | undefined; @@ -45,6 +47,7 @@ type VoidListener = () => void | null | undefined; */ export type CpsMenuItem = { title?: string; + ariaLabel?: string; action?: (event?: any) => void; icon?: string; desc?: string; @@ -243,6 +246,10 @@ export class CpsMenuComponent implements AfterViewInit, OnDestroy, OnChanges { itemsClasses: string[] = []; hideReason: CpsMenuHideReason | undefined; + private _openedByKeyboard = false; + private readonly _cpsInputModalityService = inject( + CPS_INPUT_MODALITY_SERVICE + ); @ViewChild('menuArrow') private _menuArrow?: ElementRef; @@ -274,6 +281,18 @@ export class CpsMenuComponent implements AfterViewInit, OnDestroy, OnChanges { if (this.compressed) this.withIcons = this.items.some((itm) => itm.icon); this._setItemsClasses(); } + + if (changes.items) { + const hasItemsA11yViolation = this.items.some( + (item) => !item.title?.trim() && !item.ariaLabel?.trim() + ); + + if (hasItemsA11yViolation) { + console.error( + 'CpsMenuComponent: all untitled menu items must have an ariaLabel for accessibility.' + ); + } + } } ngAfterViewInit(): void { @@ -321,6 +340,8 @@ export class CpsMenuComponent implements AfterViewInit, OnDestroy, OnChanges { this.target = target || event?.currentTarget || event?.target; if (this.target) this.resizeObserver.observe(this.target); + this._openedByKeyboard = + this._cpsInputModalityService?.lastInput() === 'keyboard'; this.overlayVisible = true; this.render = true; this.position = pos || 'default'; @@ -390,7 +411,7 @@ export class CpsMenuComponent implements AfterViewInit, OnDestroy, OnChanges { this.zone.run(() => { this.hide(CpsMenuHideReason.KEYDOWN_ESCAPE); }); - (this.target as HTMLElement)?.focus(); + this._focusTarget(this._openedByKeyboard); break; case 'Tab': if (this.items.length > 0) { @@ -708,6 +729,22 @@ export class CpsMenuComponent implements AfterViewInit, OnDestroy, OnChanges { }); } + private _focusTarget(showRing: boolean): void { + const el: HTMLElement | undefined | null = this.target; + if (!el) return; + if (!showRing) { + el.classList.add('suppress-focus-visible'); + el.addEventListener( + 'blur', + () => el.classList.remove('suppress-focus-visible'), + { + once: true + } + ); + } + el.focus(); + } + private _getMenuItems(): HTMLElement[] { if (!this.container) return []; return Array.from( diff --git a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.html b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.html index ef7ddbf0..3723597a 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.html @@ -1,50 +1,78 @@
- @for (item of items; track item) { - @if (item.url) { - - - - {{ item.title }} - - + [style.height]="cvtHeight()" + [class.cps-sidebar-menu-collapsed]="!isExpanded"> + +
diff --git a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.scss b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.scss index a6760851..41db8576 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.scss @@ -1,11 +1,13 @@ +@use '../../../../styles/mixins' as *; + :host { - $sidebar-width: 80px; - $sidebar-width-collapsed: 40px; - $sidebar-item-height: 80px; + $sidebar-width: 5rem; + $sidebar-width-collapsed: 2.5rem; + $sidebar-item-height: 5rem; $sidebar-item-hover-background: var(--cps-color-highlight-hover); $sidebar-item-active-background: var(--cps-color-highlight-active); $sidebar-item-content-color: var(--cps-color-text-darkest); - $sidebar-item-content-disabled-color: var(--cps-color-text-light); + $sidebar-item-content-disabled-color: var(--cps-color-text-mild); $sidebar-border-color: var(--cps-color-line-mid); $color-calm: var(--cps-color-calm); $transition-duration: 0.2s; @@ -15,21 +17,40 @@ flex-direction: column; justify-content: flex-start; align-items: center; - box-shadow: 0 0 60px 0 rgba(0, 0, 0, 0.1); + box-shadow: 0 0 3.75rem 0 rgba(0, 0, 0, 0.1); width: $sidebar-width; transition-duration: $transition-duration; overflow: hidden; position: relative; + .cps-sidebar-menubar { + width: 100%; + } + .expand-area { cursor: pointer; display: flex; justify-content: center; - height: 24px; + height: 1.5rem; position: absolute; bottom: 5%; - transition-duration: $transition-duration; - &:hover { + background: none; + border: none; + padding: 0; + font: inherit; + color: inherit; + + cps-icon { + transition: transform $transition-duration cubic-bezier(0.4, 0, 0.2, 1); + } + + &:focus-visible { + @include focus-ring(0, -0.125rem, 0); + position: absolute; + } + + &:hover, + &:focus-visible { color: $color-calm; } } @@ -43,21 +64,37 @@ height: $sidebar-item-height; text-decoration: none; color: $sidebar-item-content-color; - border-bottom: 1px solid $sidebar-border-color; - -webkit-user-select: none; /* Safari */ - -ms-user-select: none; /* IE 10 and IE 11 */ - user-select: none; /* Standard syntax */ + border-bottom: 0.0625rem solid $sidebar-border-color; + user-select: none; + background: none; + border-left: none; + border-right: none; + border-top: none; + padding: 0; + font: inherit; + + &:focus-visible { + @include focus-ring(0, -0.125rem, 0); + } + + &:focus-visible:not(.active):not(.disabled) { + background: $sidebar-item-hover-background; + color: $color-calm; + .cps-sidebar-menu-item-label { + color: $color-calm; + } + } .cps-sidebar-menu-item-label { font-weight: 600; - font-size: 11px; - line-height: 13px; + font-size: 0.6875rem; + line-height: 0.8125rem; width: $sidebar-width; color: $sidebar-item-content-color; text-align: center; } - &:hover:not(.active) { + &:hover:not(.active):not(.disabled) { background: $sidebar-item-hover-background; color: $color-calm; .cps-sidebar-menu-item-label { @@ -65,7 +102,7 @@ } } - &:active:not(.active) { + &:active:not(.active):not(.disabled) { background: $sidebar-item-active-background; } @@ -77,7 +114,7 @@ } } - &.menu-open:not(.active) { + &.menu-open:not(.active):not(.disabled) { background: $sidebar-item-active-background; color: $color-calm; .cps-sidebar-menu-item-label { @@ -91,7 +128,7 @@ &.disabled { cursor: default; - pointer-events: none; + background: var(--cps-color-bg-disabled); color: $sidebar-item-content-disabled-color; .cps-sidebar-menu-item-label { color: $sidebar-item-content-disabled-color; @@ -102,7 +139,9 @@ .cps-sidebar-menu.cps-sidebar-menu-collapsed { width: $sidebar-width-collapsed; .expand-area { - transform: rotate(180deg); + cps-icon { + transform: rotate(180deg); + } } } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.spec.ts new file mode 100644 index 00000000..1f261f8b --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.spec.ts @@ -0,0 +1,502 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideRouter, Router } from '@angular/router'; +import { CpsMenuComponent } from '../cps-menu/cps-menu.component'; +import { + CpsSidebarMenuComponent, + CpsSidebarMenuItem +} from './cps-sidebar-menu.component'; + +describe('CpsSidebarMenuComponent', () => { + let component: CpsSidebarMenuComponent; + let fixture: ComponentFixture; + let router: Router; + + const sampleItems: CpsSidebarMenuItem[] = [ + { title: 'Home', icon: 'home', url: '/home' }, + { title: 'Settings', icon: 'settings', url: '/settings', disabled: true }, + { + title: 'Reports', + icon: 'reports', + items: [ + { title: 'Monthly', url: '/reports/monthly' }, + { title: 'Annual', url: '/reports/annual' } + ] + } + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CpsSidebarMenuComponent, NoopAnimationsModule], + providers: [provideRouter([])], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + router = TestBed.inject(Router); + fixture = TestBed.createComponent(CpsSidebarMenuComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have default values', () => { + expect(component.items).toEqual([]); + expect(component.isExpanded).toBe(true); + expect(component.exactRoutes).toBe(false); + expect(component.ariaLabel).toBe('Main navigation'); + }); + + it('should convert numeric height to px string', () => { + fixture.componentRef.setInput('height', 500); + expect(component.cvtHeight()).toBe('500px'); + }); + + it('should preserve percentage height string', () => { + fixture.componentRef.setInput('height', '80%'); + expect(component.cvtHeight()).toBe('80%'); + }); + + it('should preserve rem height string', () => { + fixture.componentRef.setInput('height', '10rem'); + expect(component.cvtHeight()).toBe('10rem'); + }); + + describe('toggleSidebar', () => { + it('should collapse when expanded', () => { + component.isExpanded = true; + component.toggleSidebar(); + expect(component.isExpanded).toBe(false); + }); + + it('should expand when collapsed', () => { + component.isExpanded = false; + component.toggleSidebar(); + expect(component.isExpanded).toBe(true); + }); + }); + + describe('isActive', () => { + it('should return false when item has no sub-items', () => { + const item: CpsSidebarMenuItem = { + title: 'Home', + icon: 'home', + url: '/home' + }; + expect(component.isActive(item)).toBe(false); + }); + + it('should return false when sub-items have no URLs', () => { + const item: CpsSidebarMenuItem = { + title: 'Reports', + icon: 'reports', + items: [{ title: 'Monthly' }] + }; + expect(component.isActive(item)).toBe(false); + }); + + it('should return true when current URL partially matches a sub-item URL', () => { + const item: CpsSidebarMenuItem = { + title: 'Reports', + icon: 'reports', + items: [{ title: 'Monthly', url: '/reports/monthly' }] + }; + jest.spyOn(router, 'url', 'get').mockReturnValue('/reports/monthly'); + expect(component.isActive(item)).toBe(true); + }); + + it('should return false when current URL does not match any sub-item URL', () => { + const item: CpsSidebarMenuItem = { + title: 'Reports', + icon: 'reports', + items: [{ title: 'Monthly', url: '/reports/monthly' }] + }; + jest.spyOn(router, 'url', 'get').mockReturnValue('/home'); + expect(component.isActive(item)).toBe(false); + }); + + it('should use partial match when exactRoutes is false', () => { + component.exactRoutes = false; + const item: CpsSidebarMenuItem = { + title: 'Reports', + icon: 'reports', + items: [{ title: 'Reports', url: '/reports' }] + }; + jest.spyOn(router, 'url', 'get').mockReturnValue('/reports/monthly'); + expect(component.isActive(item)).toBe(true); + }); + + it('should use exact match when exactRoutes is true', () => { + component.exactRoutes = true; + const item: CpsSidebarMenuItem = { + title: 'Reports', + icon: 'reports', + items: [{ title: 'Reports', url: '/reports' }] + }; + jest.spyOn(router, 'url', 'get').mockReturnValue('/reports/monthly'); + expect(component.isActive(item)).toBe(false); + }); + + it('should match exactly when exactRoutes is true and URL matches', () => { + component.exactRoutes = true; + const item: CpsSidebarMenuItem = { + title: 'Reports', + icon: 'reports', + items: [{ title: 'Reports', url: '/reports' }] + }; + jest.spyOn(router, 'url', 'get').mockReturnValue('/reports'); + expect(component.isActive(item)).toBe(true); + }); + }); + + describe('showMenu', () => { + let mockMenu: Pick; + + beforeEach(() => { + mockMenu = { + isVisible: jest.fn().mockReturnValue(false), + show: jest.fn(), + hide: jest.fn() + } as unknown as Pick; + component.allMenus = { forEach: jest.fn() } as any; + }); + + it('should not show menu when target element has disabled class', () => { + const el = document.createElement('button'); + el.classList.add('disabled'); + const event = { type: 'click', currentTarget: el } as any; + component.showMenu(event, mockMenu as CpsMenuComponent); + expect(mockMenu.show).not.toHaveBeenCalled(); + }); + + it('should set focusedItemWithMenu on focusin event', () => { + const el = document.createElement('button'); + const item: CpsSidebarMenuItem = { title: 'Reports', icon: 'reports' }; + const event = { type: 'focusin', currentTarget: el } as any; + component.showMenu(event, mockMenu as CpsMenuComponent, item); + expect(component.focusedItemWithMenu).toBe(item); + }); + + it('should not set focusedItemWithMenu when no item is provided', () => { + const el = document.createElement('button'); + const event = { type: 'focusin', currentTarget: el } as any; + component.showMenu(event, mockMenu as CpsMenuComponent); + expect(component.focusedItemWithMenu).toBeNull(); + }); + + it('should hide all other menus and show this one when not visible', () => { + const el = document.createElement('button'); + const event = { type: 'mouseenter', currentTarget: el } as any; + (mockMenu.isVisible as jest.Mock).mockReturnValue(false); + component.showMenu(event, mockMenu as CpsMenuComponent); + expect((component.allMenus as any).forEach).toHaveBeenCalled(); + expect(mockMenu.show).toHaveBeenCalledWith(null, el, 'tr'); + }); + + it('should call show again on focusin when menu is already visible', () => { + const el = document.createElement('button'); + const item: CpsSidebarMenuItem = { title: 'Reports', icon: 'reports' }; + const event = { type: 'focusin', currentTarget: el } as any; + (mockMenu.isVisible as jest.Mock).mockReturnValue(true); + component.showMenu(event, mockMenu as CpsMenuComponent, item); + expect(mockMenu.show).toHaveBeenCalledWith(null, el, 'tr'); + }); + + it('should not call show on non-focusin event when menu is already visible', () => { + const el = document.createElement('button'); + const event = { type: 'mouseenter', currentTarget: el } as any; + (mockMenu.isVisible as jest.Mock).mockReturnValue(true); + component.showMenu(event, mockMenu as CpsMenuComponent); + expect(mockMenu.show).not.toHaveBeenCalled(); + }); + }); + + describe('toggleMenu', () => { + let mockMenu: Pick; + let item: CpsSidebarMenuItem; + + beforeEach(() => { + mockMenu = { + isVisible: jest.fn().mockReturnValue(false), + show: jest.fn(), + hide: jest.fn() + } as unknown as Pick; + item = { title: 'Reports', icon: 'reports' }; + component.allMenus = { forEach: jest.fn() } as any; + }); + + it('should not toggle when target element has disabled class', () => { + const el = document.createElement('button'); + el.classList.add('disabled'); + const event = { currentTarget: el } as MouseEvent; + component.toggleMenu(event, mockMenu as CpsMenuComponent, item); + expect(mockMenu.show).not.toHaveBeenCalled(); + expect(mockMenu.hide).not.toHaveBeenCalled(); + }); + + it('should hide menu when it is visible', () => { + const el = document.createElement('button'); + const event = { currentTarget: el } as MouseEvent; + (mockMenu.isVisible as jest.Mock).mockReturnValue(true); + component.toggleMenu(event, mockMenu as CpsMenuComponent, item); + expect(mockMenu.hide).toHaveBeenCalled(); + expect(mockMenu.show).not.toHaveBeenCalled(); + }); + + it('should hide all other menus and show this one when not visible', () => { + const el = document.createElement('button'); + const event = { currentTarget: el } as MouseEvent; + (mockMenu.isVisible as jest.Mock).mockReturnValue(false); + component.toggleMenu(event, mockMenu as CpsMenuComponent, item); + expect((component.allMenus as any).forEach).toHaveBeenCalled(); + expect(mockMenu.show).toHaveBeenCalledWith(null, el, 'tr'); + }); + + it('should always set focusedItemWithMenu to the given item', () => { + const el = document.createElement('button'); + const event = { currentTarget: el } as MouseEvent; + component.toggleMenu(event, mockMenu as CpsMenuComponent, item); + expect(component.focusedItemWithMenu).toBe(item); + }); + }); + + describe('leaveMenu', () => { + let mockMenu: { + hide: jest.Mock; + container: HTMLElement | null; + target: HTMLElement | null; + }; + + beforeEach(() => { + mockMenu = { + hide: jest.fn(), + container: document.createElement('div'), + target: document.createElement('button') + }; + }); + + it('should hide menu when related target is outside container and target', () => { + const externalEl = document.createElement('div'); + const event = { type: 'mouseleave', relatedTarget: externalEl } as any; + component.leaveMenu(event, mockMenu as any); + expect(mockMenu.hide).toHaveBeenCalled(); + }); + + it('should not hide menu when related target is inside container', () => { + const inner = document.createElement('span'); + mockMenu.container!.appendChild(inner); + const event = { type: 'mouseleave', relatedTarget: inner } as any; + component.leaveMenu(event, mockMenu as any); + expect(mockMenu.hide).not.toHaveBeenCalled(); + }); + + it('should not hide menu when related target is inside target element', () => { + const inner = document.createElement('span'); + mockMenu.target!.appendChild(inner); + const event = { type: 'mouseleave', relatedTarget: inner } as any; + component.leaveMenu(event, mockMenu as any); + expect(mockMenu.hide).not.toHaveBeenCalled(); + }); + + it('should reset focusedItemWithMenu on focusout when hiding', () => { + component.focusedItemWithMenu = { title: 'Test', icon: 'icon' }; + const externalEl = document.createElement('div'); + const event = { type: 'focusout', relatedTarget: externalEl } as any; + component.leaveMenu(event, mockMenu as any); + expect(component.focusedItemWithMenu).toBeNull(); + }); + + it('should not reset focusedItemWithMenu on mouseleave', () => { + const focusedItem: CpsSidebarMenuItem = { title: 'Test', icon: 'icon' }; + component.focusedItemWithMenu = focusedItem; + const externalEl = document.createElement('div'); + const event = { type: 'mouseleave', relatedTarget: externalEl } as any; + component.leaveMenu(event, mockMenu as any); + expect(component.focusedItemWithMenu).toBe(focusedItem); + }); + }); + + describe('template', () => { + it('should apply the given height style', () => { + fixture.componentRef.setInput('height', '200px'); + fixture.detectChanges(); + const el = fixture.nativeElement.querySelector('.cps-sidebar-menu'); + expect(el.style.height).toBe('200px'); + }); + + it('should render nav with correct aria-label', () => { + fixture.componentRef.setInput('ariaLabel', 'Side navigation'); + fixture.detectChanges(); + const nav = fixture.nativeElement.querySelector('nav'); + expect(nav.getAttribute('aria-label')).toBe('Side navigation'); + }); + + it('should render aria-description as expanded when isExpanded is true', () => { + fixture.componentRef.setInput('isExpanded', true); + fixture.detectChanges(); + const nav = fixture.nativeElement.querySelector('nav'); + expect(nav.getAttribute('aria-description')).toBe('Sidebar expanded'); + }); + + it('should render aria-description as collapsed when isExpanded is false', () => { + fixture.componentRef.setInput('isExpanded', false); + fixture.detectChanges(); + const nav = fixture.nativeElement.querySelector('nav'); + expect(nav.getAttribute('aria-description')).toBe('Sidebar collapsed'); + }); + + it('should add collapsed class when isExpanded is false', () => { + fixture.componentRef.setInput('isExpanded', false); + fixture.detectChanges(); + const el = fixture.nativeElement.querySelector('.cps-sidebar-menu'); + expect(el.classList.contains('cps-sidebar-menu-collapsed')).toBe(true); + }); + + it('should not add collapsed class when isExpanded is true', () => { + fixture.componentRef.setInput('isExpanded', true); + fixture.detectChanges(); + const el = fixture.nativeElement.querySelector('.cps-sidebar-menu'); + expect(el.classList.contains('cps-sidebar-menu-collapsed')).toBe(false); + }); + + it('should render expand button with Collapse sidebar aria-label when expanded', () => { + fixture.componentRef.setInput('isExpanded', true); + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector('.expand-area'); + expect(btn.getAttribute('aria-label')).toBe('Collapse sidebar'); + }); + + it('should render expand button with Expand sidebar aria-label when collapsed', () => { + fixture.componentRef.setInput('isExpanded', false); + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector('.expand-area'); + expect(btn.getAttribute('aria-label')).toBe('Expand sidebar'); + }); + + it('should set aria-expanded on expand button', () => { + fixture.componentRef.setInput('isExpanded', true); + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector('.expand-area'); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should toggle sidebar when expand button is clicked', () => { + fixture.componentRef.setInput('isExpanded', true); + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector('.expand-area'); + btn.click(); + expect(component.isExpanded).toBe(false); + }); + + it('should render anchor element for items with url', () => { + fixture.componentRef.setInput('items', [ + { title: 'Home', icon: 'home', url: '/home' } + ]); + fixture.detectChanges(); + const link = fixture.nativeElement.querySelector( + 'a.cps-sidebar-menu-item' + ); + expect(link).toBeTruthy(); + }); + + it('should set aria-label on anchor item', () => { + fixture.componentRef.setInput('items', [ + { title: 'Home', icon: 'home', url: '/home' } + ]); + fixture.detectChanges(); + const link = fixture.nativeElement.querySelector( + 'a.cps-sidebar-menu-item' + ); + expect(link.getAttribute('aria-label')).toBe('Home'); + }); + + it('should apply disabled class to disabled link item', () => { + fixture.componentRef.setInput('items', [ + { + title: 'Settings', + icon: 'settings', + url: '/settings', + disabled: true + } + ]); + fixture.detectChanges(); + const link = fixture.nativeElement.querySelector( + '.cps-sidebar-menu-item' + ); + expect(link.classList.contains('disabled')).toBe(true); + }); + + it('should set aria-disabled on disabled link item', () => { + fixture.componentRef.setInput('items', [ + { + title: 'Settings', + icon: 'settings', + url: '/settings', + disabled: true + } + ]); + fixture.detectChanges(); + const link = fixture.nativeElement.querySelector( + '.cps-sidebar-menu-item' + ); + expect(link.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should render button element for items without url', () => { + fixture.componentRef.setInput('items', [ + { + title: 'Reports', + icon: 'reports', + items: [{ title: 'Monthly', url: '/reports/monthly' }] + } + ]); + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector( + 'button.cps-sidebar-menu-item' + ); + expect(btn).toBeTruthy(); + }); + + it('should set aria-haspopup on button menu trigger', () => { + fixture.componentRef.setInput('items', [ + { + title: 'Reports', + icon: 'reports', + items: [{ title: 'Monthly', url: '/reports/monthly' }] + } + ]); + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector( + 'button.cps-sidebar-menu-item' + ); + expect(btn.getAttribute('aria-haspopup')).toBe('menu'); + }); + + it('should set aria-label on button menu trigger', () => { + fixture.componentRef.setInput('items', [ + { + title: 'Reports', + icon: 'reports', + items: [{ title: 'Monthly', url: '/reports/monthly' }] + } + ]); + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector( + 'button.cps-sidebar-menu-item' + ); + expect(btn.getAttribute('aria-label')).toBe('Reports'); + }); + + it('should render all provided items', () => { + fixture.componentRef.setInput('items', sampleItems); + fixture.detectChanges(); + const menuItems = fixture.nativeElement.querySelectorAll( + '.cps-sidebar-menu-item' + ); + expect(menuItems.length).toBe(sampleItems.length); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.ts b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.ts index 9f5f3990..15a8039d 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.ts @@ -1,9 +1,10 @@ import { Component, Input, - OnInit, QueryList, - ViewChildren + ViewChildren, + computed, + input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router, RouterModule } from '@angular/router'; @@ -54,7 +55,7 @@ export type CpsSidebarMenuItem = { state( 'expanded', style({ - marginTop: '6px', + marginTop: '0.375rem', opacity: '1' }) ), @@ -64,7 +65,7 @@ export type CpsSidebarMenuItem = { ]) ] }) -export class CpsSidebarMenuComponent implements OnInit { +export class CpsSidebarMenuComponent { /** * An array of menu items. * @group Props @@ -83,25 +84,74 @@ export class CpsSidebarMenuComponent implements OnInit { */ @Input() exactRoutes = false; + /** + * Aria label for the sidebar, used for accessibility. + * @group Props + */ + @Input() ariaLabel = 'Main navigation'; + /** * Height of the sidebar, of type number denoting pixels or string. * @group Props + * @default 100% */ - @Input() height: number | string = '100%'; + height = input('100%'); @ViewChildren('popupMenu') allMenus?: QueryList; + focusedItemWithMenu: CpsSidebarMenuItem | null = null; + // eslint-disable-next-line no-useless-constructor constructor(private _router: Router) {} - ngOnInit(): void { - this.height = convertSize(this.height); - } + cvtHeight = computed(() => convertSize(this.height())); - toggleMenu(event: any, menu: CpsMenuComponent) { - const isVisible = menu.isVisible(); + showMenu( + event: MouseEvent | FocusEvent, + menu: CpsMenuComponent, + item?: CpsSidebarMenuItem + ) { + if ((event.currentTarget as HTMLElement)?.classList.contains('disabled')) + return; + if (event.type === 'focusin' && item) { + this.focusedItemWithMenu = item; + } + if (menu.isVisible()) { + if (event.type === 'focusin') menu.show(null, event.currentTarget, 'tr'); + return; + } this.allMenus?.forEach((m) => m.hide()); - if (!isVisible) menu.toggle(event, event.currentTarget, 'tr'); + menu.show(null, event.currentTarget, 'tr'); + } + + toggleMenu( + event: MouseEvent, + menu: CpsMenuComponent, + item: CpsSidebarMenuItem + ) { + if ((event.currentTarget as HTMLElement)?.classList.contains('disabled')) + return; + + this.focusedItemWithMenu = item; + if (menu.isVisible()) { + menu.hide(); + } else { + this.allMenus?.forEach((m) => m.hide()); + menu.show(null, event.currentTarget as HTMLElement, 'tr'); + } + } + + leaveMenu(event: MouseEvent | FocusEvent, menu: CpsMenuComponent) { + const rel = event.relatedTarget as Node; + if ( + !menu.container?.contains(rel) && + !(menu.target as HTMLElement)?.contains(rel) + ) { + menu.hide(); + if (event.type === 'focusout') { + this.focusedItemWithMenu = null; + } + } } isActive(item: CpsSidebarMenuItem) { diff --git a/projects/cps-ui-kit/src/lib/services/cps-input-modality/cps-input-modality.service.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-input-modality/cps-input-modality.service.spec.ts new file mode 100644 index 00000000..e497f801 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/services/cps-input-modality/cps-input-modality.service.spec.ts @@ -0,0 +1,122 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { DOCUMENT } from '@angular/common'; +import { + CpsInputModalityService, + CPS_INPUT_MODALITY_SERVICE +} from './cps-input-modality.service'; + +describe('CpsInputModalityService', () => { + let service: CpsInputModalityService; + let document: Document; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CpsInputModalityService); + document = TestBed.inject(DOCUMENT); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should default lastInput to "pointer"', () => { + expect(service.lastInput()).toBe('pointer'); + }); + + describe('keyboard navigation keys', () => { + const navigationKeys = [ + 'Tab', + 'Enter', + ' ', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + 'PageUp', + 'PageDown' + ]; + + navigationKeys.forEach((key) => { + it(`should set lastInput to "keyboard" on "${key}" keydown`, () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key })); + expect(service.lastInput()).toBe('keyboard'); + }); + }); + }); + + describe('non-navigation keys', () => { + it('should NOT change lastInput when a non-navigation key is pressed', () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + expect(service.lastInput()).toBe('pointer'); + }); + + it('should NOT change lastInput for "Shift" keydown', () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' })); + expect(service.lastInput()).toBe('pointer'); + }); + + it('should NOT change lastInput for "Escape" keydown', () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(service.lastInput()).toBe('pointer'); + }); + }); + + it('should reset lastInput to "pointer" on pointerdown after keyboard input', () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + expect(service.lastInput()).toBe('keyboard'); + + document.dispatchEvent(new Event('pointerdown')); + expect(service.lastInput()).toBe('pointer'); + }); + + it('should remain "pointer" when pointerdown fires without prior keyboard input', () => { + document.dispatchEvent(new Event('pointerdown')); + expect(service.lastInput()).toBe('pointer'); + }); + + it('should alternate correctly between keyboard and pointer events', () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(service.lastInput()).toBe('keyboard'); + + document.dispatchEvent(new Event('pointerdown')); + expect(service.lastInput()).toBe('pointer'); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(service.lastInput()).toBe('keyboard'); + }); +}); + +describe('CpsInputModalityService (SSR)', () => { + it('should not register event listeners outside browser', () => { + const addEventListenerSpy = jest.spyOn( + Document.prototype, + 'addEventListener' + ); + + TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'server' }, + CpsInputModalityService + ] + }); + TestBed.inject(CpsInputModalityService); + + expect(addEventListenerSpy).not.toHaveBeenCalled(); + }); +}); + +describe('CPS_INPUT_MODALITY_SERVICE token', () => { + it('should resolve to the CpsInputModalityService instance', () => { + TestBed.configureTestingModule({}); + const token = TestBed.inject(CPS_INPUT_MODALITY_SERVICE); + const service = TestBed.inject(CpsInputModalityService); + expect(token).toBe(service); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/services/cps-input-modality/cps-input-modality.service.ts b/projects/cps-ui-kit/src/lib/services/cps-input-modality/cps-input-modality.service.ts new file mode 100644 index 00000000..a4fb805d --- /dev/null +++ b/projects/cps-ui-kit/src/lib/services/cps-input-modality/cps-input-modality.service.ts @@ -0,0 +1,91 @@ +import { + inject, + Injectable, + InjectionToken, + PLATFORM_ID, + signal +} from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; + +/** + * Tracks the most recent user input modality (keyboard vs. pointer) by + * listening to `keydown` and `pointerdown` events on the document. + * + * Useful for conditionally showing focus rings only when the user is navigating + * via keyboard, keeping the UI clean for pointer users. + * + * Only active in browser environments; no-ops under SSR. + * + * @example + * ```ts + * readonly inputModality = inject(CpsInputModalityService); + * readonly showFocusRing = computed(() => this.inputModality.lastInput() === 'keyboard'); + * ``` + */ +@Injectable({ providedIn: 'root' }) +export class CpsInputModalityService { + private readonly _platformId = inject(PLATFORM_ID); + private readonly _document = inject(DOCUMENT); + + /** + * A signal reflecting the most recently detected input modality. + * - `'keyboard'` — set when a navigation key (Tab, Enter, Space, Arrow keys, + * Home, End, PageUp, PageDown) is pressed. + * - `'pointer'` — set on every `pointerdown` event (mouse, touch, stylus). + * + * Defaults to `'pointer'`. + */ + readonly lastInput = signal<'keyboard' | 'pointer'>('pointer'); + + private readonly _navigationKeys = new Set([ + 'Tab', + 'Enter', + ' ', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + 'PageUp', + 'PageDown' + ]); + + constructor() { + if (!isPlatformBrowser(this._platformId)) return; + + this._document.addEventListener('keydown', (e) => { + if (this._navigationKeys.has(e.key)) { + this.lastInput.set('keyboard'); + } + }); + + this._document.addEventListener('pointerdown', () => { + this.lastInput.set('pointer'); + }); + } +} + +/** + * Injection token for the input modality service. + * + * By default it resolves to the singleton {@link CpsInputModalityService}. + * Consumer applications can override it to: + * - Supply a custom subclass + * - Provide `null` to disable dynamic tracking entirely + * + * @example Disable dynamic tracking: + * ```typescript + * providers: [ + * { provide: CPS_INPUT_MODALITY_SERVICE, useValue: null } + * ] + * ``` + */ +export const CPS_INPUT_MODALITY_SERVICE = + new InjectionToken( + 'CpsInputModalityService', + { + providedIn: 'root', + factory: () => inject(CpsInputModalityService) + } + ); diff --git a/projects/cps-ui-kit/src/public-api.ts b/projects/cps-ui-kit/src/public-api.ts index 97b6370c..c114ab38 100644 --- a/projects/cps-ui-kit/src/public-api.ts +++ b/projects/cps-ui-kit/src/public-api.ts @@ -59,5 +59,6 @@ export * from './lib/services/cps-dialog/utils/cps-dialog-ref'; export * from './lib/services/cps-notification/cps-notification.service'; export * from './lib/services/cps-notification/utils/cps-notification-config'; +export * from './lib/services/cps-input-modality/cps-input-modality.service'; export * from './lib/services/cps-theme/cps-theme.service'; export * from './lib/utils/colors-utils'; diff --git a/projects/cps-ui-kit/styles/_mixins.scss b/projects/cps-ui-kit/styles/_mixins.scss index df29e068..ee6fe15b 100644 --- a/projects/cps-ui-kit/styles/_mixins.scss +++ b/projects/cps-ui-kit/styles/_mixins.scss @@ -22,12 +22,17 @@ } &::before { - inset: -#{$inner-offset}; /* inner offset */ + inset: #{-$inner-offset}; border: $inner-width solid var(--cps-color-calm); } &::after { - inset: -#{$outer-offset}; /* outer offset */ + inset: #{-$outer-offset}; border: $outer-width solid var(--cps-color-calm-highlighten); } + + &.suppress-focus-visible::before, + &.suppress-focus-visible::after { + display: none; + } }