From 5214e638570cb131ee9e06e6659a68054dabd21b Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 6 May 2026 15:54:12 +0200 Subject: [PATCH 1/8] Fix a11y issues in sidebar menu component Co-authored-by: Copilot --- api-generator/api-generator.js | 14 +- playwright/cps-accessibility.spec.ts | 10 +- .../src/app/api-data/cps-button-toggle.json | 2 +- .../src/app/api-data/cps-menu.json | 2 +- .../src/app/api-data/cps-radio-group.json | 2 +- .../src/app/api-data/cps-sidebar-menu.json | 10 +- .../src/app/api-data/cps-table.json | 2 +- .../pages/menu-page/menu-page.component.ts | 2 + .../sidebar-menu-page.component.scss | 8 +- .../cps-menu/cps-menu.component.html | 3 + .../components/cps-menu/cps-menu.component.ts | 36 +- .../cps-sidebar-menu.component.html | 122 +++-- .../cps-sidebar-menu.component.scss | 77 ++- .../cps-sidebar-menu.component.spec.ts | 506 ++++++++++++++++++ .../cps-sidebar-menu.component.ts | 68 ++- .../lib/services/input-modality.service.ts | 36 ++ projects/cps-ui-kit/styles/_mixins.scss | 9 +- 17 files changed, 812 insertions(+), 97 deletions(-) create mode 100644 projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.spec.ts create mode 100644 projects/cps-ui-kit/src/lib/services/input-modality.service.ts 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..5f880182 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 { INPUT_MODALITY_SERVICE } from '../../services/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,8 @@ export class CpsMenuComponent implements AfterViewInit, OnDestroy, OnChanges { itemsClasses: string[] = []; hideReason: CpsMenuHideReason | undefined; + private _openedByKeyboard = false; + private readonly _inputModalityService = inject(INPUT_MODALITY_SERVICE); @ViewChild('menuArrow') private _menuArrow?: ElementRef; @@ -274,6 +279,15 @@ export class CpsMenuComponent implements AfterViewInit, OnDestroy, OnChanges { if (this.compressed) this.withIcons = this.items.some((itm) => itm.icon); this._setItemsClasses(); } + 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 +335,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._inputModalityService?.lastInput() === 'keyboard'; this.overlayVisible = true; this.render = true; this.position = pos || 'default'; @@ -390,7 +406,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 +724,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..4deebe34 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,80 @@
- @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..08317166 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.spec.ts @@ -0,0 +1,506 @@ +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 on init', () => { + fixture.componentRef.setInput('height', 500); + component.ngOnInit(); + expect(component.height).toBe('500px'); + }); + + it('should preserve percentage height string on init', () => { + fixture.componentRef.setInput('height', '80%'); + component.ngOnInit(); + expect(component.height).toBe('80%'); + }); + + it('should preserve rem height string on init', () => { + fixture.componentRef.setInput('height', '10rem'); + component.ngOnInit(); + expect(component.height).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'); + component.ngOnInit(); + 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..270e8b19 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,70 @@ 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: any, 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; + if (menu.isVisible()) { + this.focusedItemWithMenu = item; + menu.hide(); + } else { + this.focusedItemWithMenu = item; + this.allMenus?.forEach((m) => m.hide()); + menu.show(null, event.currentTarget as HTMLElement, 'tr'); + } + } + + leaveMenu(event: MouseEvent | FocusEvent, menu: CpsMenuComponent) { + const rel = (event as any).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/input-modality.service.ts b/projects/cps-ui-kit/src/lib/services/input-modality.service.ts new file mode 100644 index 00000000..46d1cdfe --- /dev/null +++ b/projects/cps-ui-kit/src/lib/services/input-modality.service.ts @@ -0,0 +1,36 @@ +import { + inject, + Injectable, + InjectionToken, + PLATFORM_ID, + signal +} from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; + +@Injectable({ providedIn: 'root' }) +class InputModalityService { + private readonly _platformId = inject(PLATFORM_ID); + private readonly _document = inject(DOCUMENT); + + readonly lastInput = signal<'keyboard' | 'pointer'>('pointer'); + + constructor() { + if (!isPlatformBrowser(this._platformId)) return; + + this._document.addEventListener('keydown', (e) => { + if (e.key === 'Tab' || e.key === 'Enter' || e.key === ' ') { + this.lastInput.set('keyboard'); + } + }); + + this._document.addEventListener('pointerdown', () => { + this.lastInput.set('pointer'); + }); + } +} + +export const INPUT_MODALITY_SERVICE = + new InjectionToken('InputModalityService', { + providedIn: 'root', + factory: () => inject(InputModalityService) + }); 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; + } } From b41ca2c537f06f39678b9d97050a427a73e55c46 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 6 May 2026 17:01:59 +0200 Subject: [PATCH 2/8] fix stale UT Co-authored-by: Copilot --- .../cps-sidebar-menu.component.spec.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) 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 index 08317166..1f261f8b 100644 --- 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 @@ -50,22 +50,19 @@ describe('CpsSidebarMenuComponent', () => { expect(component.ariaLabel).toBe('Main navigation'); }); - it('should convert numeric height to px string on init', () => { + it('should convert numeric height to px string', () => { fixture.componentRef.setInput('height', 500); - component.ngOnInit(); - expect(component.height).toBe('500px'); + expect(component.cvtHeight()).toBe('500px'); }); - it('should preserve percentage height string on init', () => { + it('should preserve percentage height string', () => { fixture.componentRef.setInput('height', '80%'); - component.ngOnInit(); - expect(component.height).toBe('80%'); + expect(component.cvtHeight()).toBe('80%'); }); - it('should preserve rem height string on init', () => { + it('should preserve rem height string', () => { fixture.componentRef.setInput('height', '10rem'); - component.ngOnInit(); - expect(component.height).toBe('10rem'); + expect(component.cvtHeight()).toBe('10rem'); }); describe('toggleSidebar', () => { @@ -325,7 +322,6 @@ describe('CpsSidebarMenuComponent', () => { describe('template', () => { it('should apply the given height style', () => { fixture.componentRef.setInput('height', '200px'); - component.ngOnInit(); fixture.detectChanges(); const el = fixture.nativeElement.querySelector('.cps-sidebar-menu'); expect(el.style.height).toBe('200px'); From 5a0673d3b423038b2b20adb88486f616cd5b75c9 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 6 May 2026 17:06:23 +0200 Subject: [PATCH 3/8] update InputModalityService Co-authored-by: Copilot --- .../src/lib/services/input-modality.service.ts | 17 +++++++++++++++-- projects/cps-ui-kit/src/public-api.ts | 2 ++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/projects/cps-ui-kit/src/lib/services/input-modality.service.ts b/projects/cps-ui-kit/src/lib/services/input-modality.service.ts index 46d1cdfe..63c148cf 100644 --- a/projects/cps-ui-kit/src/lib/services/input-modality.service.ts +++ b/projects/cps-ui-kit/src/lib/services/input-modality.service.ts @@ -8,7 +8,7 @@ import { import { DOCUMENT, isPlatformBrowser } from '@angular/common'; @Injectable({ providedIn: 'root' }) -class InputModalityService { +export class InputModalityService { private readonly _platformId = inject(PLATFORM_ID); private readonly _document = inject(DOCUMENT); @@ -18,7 +18,20 @@ class InputModalityService { if (!isPlatformBrowser(this._platformId)) return; this._document.addEventListener('keydown', (e) => { - if (e.key === 'Tab' || e.key === 'Enter' || e.key === ' ') { + const navigationKeys = new Set([ + 'Tab', + 'Enter', + ' ', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + 'PageUp', + 'PageDown' + ]); + if (navigationKeys.has(e.key)) { this.lastInput.set('keyboard'); } }); diff --git a/projects/cps-ui-kit/src/public-api.ts b/projects/cps-ui-kit/src/public-api.ts index 97b6370c..a967aa09 100644 --- a/projects/cps-ui-kit/src/public-api.ts +++ b/projects/cps-ui-kit/src/public-api.ts @@ -59,5 +59,7 @@ 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/input-modality.service'; + export * from './lib/services/cps-theme/cps-theme.service'; export * from './lib/utils/colors-utils'; From 386d8a666a04d92e6bc48b413ceb8e2d6fd98f47 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 6 May 2026 17:13:46 +0200 Subject: [PATCH 4/8] add type Co-authored-by: Copilot --- .../cps-sidebar-menu/cps-sidebar-menu.component.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 270e8b19..2dfd86ff 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 @@ -106,7 +106,11 @@ export class CpsSidebarMenuComponent { cvtHeight = computed(() => convertSize(this.height())); - showMenu(event: any, menu: CpsMenuComponent, item?: CpsSidebarMenuItem) { + showMenu( + event: MouseEvent | FocusEvent, + menu: CpsMenuComponent, + item?: CpsSidebarMenuItem + ) { if ((event.currentTarget as HTMLElement)?.classList.contains('disabled')) return; if (event.type === 'focusin' && item) { @@ -138,7 +142,7 @@ export class CpsSidebarMenuComponent { } leaveMenu(event: MouseEvent | FocusEvent, menu: CpsMenuComponent) { - const rel = (event as any).relatedTarget as Node; + const rel = event.relatedTarget as Node; if ( !menu.container?.contains(rel) && !(menu.target as HTMLElement)?.contains(rel) From 299d6bcef05b6936569bfeb45070de15e25affdf Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 6 May 2026 17:18:40 +0200 Subject: [PATCH 5/8] remove menubar role Co-authored-by: Copilot --- .../cps-sidebar-menu/cps-sidebar-menu.component.html | 4 ---- 1 file changed, 4 deletions(-) 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 4deebe34..0c00ea97 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 @@ -4,14 +4,11 @@ [class.cps-sidebar-menu-collapsed]="!isExpanded">