diff --git a/.eslintrc.js b/.eslintrc.js index ac8fc674..02af0bbe 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -11,7 +11,14 @@ module.exports = { rules: { 'prettier/prettier': ['error'], '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': 'warn' + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + } + ] }, env: { browser: true, diff --git a/.github/workflows/cps-shared-ui-checkers.yml b/.github/workflows/cps-shared-ui-checkers.yml index e6068789..27a62a26 100644 --- a/.github/workflows/cps-shared-ui-checkers.yml +++ b/.github/workflows/cps-shared-ui-checkers.yml @@ -75,6 +75,24 @@ jobs: - name: Lint app run: npm run lint + typecheck: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck app + run: npm run typecheck + playwright: runs-on: ubuntu-latest steps: diff --git a/package.json b/package.json index 2df2a625..82cae83a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:coverage:cps-ui-kit": "ng test --project=cps-ui-kit --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", "test:coverage:composition": "ng test --project=composition --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", "lint": "eslint \"**/*.ts\"", + "typecheck": "tsc --noEmit", "generate-json-api": "node ./api-generator/api-generator.js", "test:a11y": "pa11y-ci --threshold 1000", "test:a11y:summary": "pa11y-ci --threshold 1000 --reporter=json > .pa11y-temp.json 2>/dev/null && jq -C '{\"Total URLs tested\": .total, \"Passed\": \"\\(.passes)/\\(.total)\", \"Total errors found\": .errors, \"Standard\": \"WCAG 2.0 AA\", \"Test engine\": \"axe-core via pa11y-ci\", \"Top 10 components with errors\": (.results | to_entries | map({component: (.key | split(\"/\") | .[-2]), errors: .value | length}) | sort_by(-.errors) | .[0:10])}' .pa11y-temp.json && rm -f .pa11y-temp.json", diff --git a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts index 812abb76..71b8a406 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts @@ -622,7 +622,7 @@ export class CpsAutocompleteComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - onChange = (event: any) => {}; + onChange = (_event: any) => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouched = () => {}; @@ -702,7 +702,7 @@ export class CpsAutocompleteComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - setDisabledState(disabled: boolean) {} + setDisabledState(_disabled: boolean) {} onBlur() { this.isActive = false; diff --git a/projects/cps-ui-kit/src/lib/components/cps-button-toggle/cps-button-toggle.component.ts b/projects/cps-ui-kit/src/lib/components/cps-button-toggle/cps-button-toggle.component.ts index 08a4985f..1c74dd4d 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-button-toggle/cps-button-toggle.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-button-toggle/cps-button-toggle.component.ts @@ -208,11 +208,11 @@ export class CpsButtonToggleComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - onChange = (event: any) => {}; + onChange = (_event: any) => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouched = () => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function - setDisabledState(disabled: boolean) {} + setDisabledState(_disabled: boolean) {} registerOnChange(fn: any) { this.onChange = fn; diff --git a/projects/cps-ui-kit/src/lib/components/cps-checkbox/cps-checkbox.component.ts b/projects/cps-ui-kit/src/lib/components/cps-checkbox/cps-checkbox.component.ts index f350bd2d..6003e73f 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-checkbox/cps-checkbox.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-checkbox/cps-checkbox.component.ts @@ -136,7 +136,7 @@ export class CpsCheckboxComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - onChange = (event: any) => {}; + onChange = (_event: any) => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouched = () => {}; @@ -166,7 +166,7 @@ export class CpsCheckboxComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - setDisabledState(disabled: boolean) {} + setDisabledState(_disabled: boolean) {} focus() { this._elementRef?.nativeElement?.querySelector('input')?.focus(); diff --git a/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.ts b/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.ts index 8114ab50..5b40b523 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.ts @@ -213,7 +213,7 @@ export class CpsDatepickerComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - onChange = (event: any) => {}; + onChange = (_event: any) => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouched = () => {}; diff --git a/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.ts b/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.ts index 237d7f44..77f521e0 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.ts @@ -337,7 +337,7 @@ export class CpsInputComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - onChange = (event: any) => {}; + onChange = (_event: any) => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouched = () => {}; @@ -383,7 +383,7 @@ export class CpsInputComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - setDisabledState(disabled: boolean) {} + setDisabledState(_disabled: boolean) {} onClickPrefixIcon() { if (!this.prefixIconClickable || this.readonly || this.disabled) return; diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.ts b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.ts index 33e0f477..181228a3 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.ts @@ -197,7 +197,7 @@ export class CpsRadioGroupComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - onChange = (event: any) => {}; + onChange = (_event: any) => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouched = () => {}; @@ -225,7 +225,7 @@ export class CpsRadioGroupComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - setDisabledState(disabled: boolean) {} + setDisabledState(_disabled: boolean) {} onBlur() { this._checkErrors(); diff --git a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.ts b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.ts index 1c047976..887762de 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.ts @@ -609,7 +609,7 @@ export class CpsSelectComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - onChange = (event: any) => {}; + onChange = (_event: any) => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouched = () => {}; @@ -648,7 +648,7 @@ export class CpsSelectComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - setDisabledState(disabled: boolean) {} + setDisabledState(_disabled: boolean) {} onBlur() { this._checkErrors(); 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 1f261f8b..2841a04a 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 @@ -231,7 +231,7 @@ describe('CpsSidebarMenuComponent', () => { 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; + const event = { currentTarget: el } as unknown as MouseEvent; component.toggleMenu(event, mockMenu as CpsMenuComponent, item); expect(mockMenu.show).not.toHaveBeenCalled(); expect(mockMenu.hide).not.toHaveBeenCalled(); @@ -239,7 +239,7 @@ describe('CpsSidebarMenuComponent', () => { it('should hide menu when it is visible', () => { const el = document.createElement('button'); - const event = { currentTarget: el } as MouseEvent; + const event = { currentTarget: el } as unknown as MouseEvent; (mockMenu.isVisible as jest.Mock).mockReturnValue(true); component.toggleMenu(event, mockMenu as CpsMenuComponent, item); expect(mockMenu.hide).toHaveBeenCalled(); @@ -248,7 +248,7 @@ describe('CpsSidebarMenuComponent', () => { it('should hide all other menus and show this one when not visible', () => { const el = document.createElement('button'); - const event = { currentTarget: el } as MouseEvent; + const event = { currentTarget: el } as unknown as MouseEvent; (mockMenu.isVisible as jest.Mock).mockReturnValue(false); component.toggleMenu(event, mockMenu as CpsMenuComponent, item); expect((component.allMenus as any).forEach).toHaveBeenCalled(); @@ -257,7 +257,7 @@ describe('CpsSidebarMenuComponent', () => { it('should always set focusedItemWithMenu to the given item', () => { const el = document.createElement('button'); - const event = { currentTarget: el } as MouseEvent; + const event = { currentTarget: el } as unknown as MouseEvent; component.toggleMenu(event, mockMenu as CpsMenuComponent, item); expect(component.focusedItemWithMenu).toBe(item); }); diff --git a/projects/cps-ui-kit/src/lib/components/cps-switch/cps-switch.component.ts b/projects/cps-ui-kit/src/lib/components/cps-switch/cps-switch.component.ts index 9cc737d3..d25cbddc 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-switch/cps-switch.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-switch/cps-switch.component.ts @@ -98,7 +98,7 @@ export class CpsSwitchComponent implements ControlValueAccessor { } // eslint-disable-next-line @typescript-eslint/no-empty-function - onChange = (event: any) => {}; + onChange = (_event: any) => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouched = () => {}; @@ -128,7 +128,7 @@ export class CpsSwitchComponent implements ControlValueAccessor { } // eslint-disable-next-line @typescript-eslint/no-empty-function - setDisabledState(disabled: boolean) {} + setDisabledState(_disabled: boolean) {} focus() { this._elementRef?.nativeElement?.querySelector('input')?.focus(); diff --git a/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.ts index e6757147..43f0cb5b 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.ts @@ -98,7 +98,7 @@ export class CpsTagComponent implements ControlValueAccessor, OnChanges { } // eslint-disable-next-line @typescript-eslint/no-empty-function - onChange = (event: any) => {}; + onChange = (_event: any) => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouched = () => {}; diff --git a/projects/cps-ui-kit/src/lib/components/cps-textarea/cps-textarea.component.ts b/projects/cps-ui-kit/src/lib/components/cps-textarea/cps-textarea.component.ts index 8cf3e679..bef55ba9 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-textarea/cps-textarea.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-textarea/cps-textarea.component.ts @@ -251,7 +251,7 @@ export class CpsTextareaComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - onChange = (event: any) => {}; + onChange = (_event: any) => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouched = () => {}; @@ -284,7 +284,7 @@ export class CpsTextareaComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - setDisabledState(disabled: boolean) {} + setDisabledState(_disabled: boolean) {} onBlur() { this._checkErrors(); diff --git a/projects/cps-ui-kit/src/lib/components/cps-timepicker/cps-timepicker.component.ts b/projects/cps-ui-kit/src/lib/components/cps-timepicker/cps-timepicker.component.ts index 716a04f3..f4ffbf19 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-timepicker/cps-timepicker.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-timepicker/cps-timepicker.component.ts @@ -224,7 +224,7 @@ export class CpsTimepickerComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - onChange = (event: any) => {}; + onChange = (_event: any) => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouched = () => {}; @@ -237,7 +237,7 @@ export class CpsTimepickerComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - setDisabledState(disabled: boolean) {} + setDisabledState(_disabled: boolean) {} writeValue(value: CpsTime | undefined) { this.value = value; diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts index 29288789..87148a0e 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component.ts @@ -302,7 +302,7 @@ export class CpsTreeAutocompleteComponent if (this.treeSelection?.length) { if (this.backspaceClickedOnce) { this.treeSelection = this.treeSelection.filter( - (v: TreeNode, index: number) => + (_v: TreeNode, index: number) => index !== this.treeSelection.length - 1 ); this.updateValue(this.treeSelectionToValue(this.treeSelection)); diff --git a/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts b/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts index 38ac9c74..8400fbb4 100644 --- a/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts +++ b/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts @@ -334,7 +334,7 @@ export class CpsBaseTreeDropdownComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - onChange = (event: any) => {}; + onChange = (_event: any) => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function onTouched = () => {}; @@ -355,7 +355,7 @@ export class CpsBaseTreeDropdownComponent } // eslint-disable-next-line @typescript-eslint/no-empty-function - setDisabledState(disabled: boolean) {} + setDisabledState(_disabled: boolean) {} onBlur() { this._checkErrors(); diff --git a/projects/cps-ui-kit/src/lib/services/cps-cron-validation/cps-cron-validation.service.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-cron-validation/cps-cron-validation.service.spec.ts index 15d16d70..5fbeb248 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-cron-validation/cps-cron-validation.service.spec.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-cron-validation/cps-cron-validation.service.spec.ts @@ -298,15 +298,15 @@ describe('CpsCronValidationService', () => { ] }); const tokenService = TestBed.inject(CPS_CRON_VALIDATION_SERVICE); - expect(tokenService.isValidCron('')).toBe(true); + expect(tokenService?.isValidCron('')).toBe(true); }); it('should delegate isValidCron to the underlying service', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({}); const tokenService = TestBed.inject(CPS_CRON_VALIDATION_SERVICE); - expect(tokenService.isValidCron('0 12 * * ? *')).toBe(true); - expect(tokenService.isValidCron('invalid')).toBe(false); + expect(tokenService?.isValidCron('0 12 * * ? *')).toBe(true); + expect(tokenService?.isValidCron('invalid')).toBe(false); }); }); }); diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.spec.ts index 998e887d..eb4ea17c 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.spec.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.spec.ts @@ -59,7 +59,7 @@ describe('CpsDialogService', () => { function setupAppendSpy() { jest .spyOn(service as any, 'appendDialogComponentToBody') - .mockImplementation((config: CpsDialogConfig) => { + .mockImplementation(() => { const dialogRef = new CpsDialogRef(); const mockRef = makeMockComponentRef(); lastCreatedMockRef = mockRef; diff --git a/projects/cps-ui-kit/src/lib/services/cps-root-font-size/cps-root-font-size.service.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-root-font-size/cps-root-font-size.service.spec.ts new file mode 100644 index 00000000..00b86e5d --- /dev/null +++ b/projects/cps-ui-kit/src/lib/services/cps-root-font-size/cps-root-font-size.service.spec.ts @@ -0,0 +1,248 @@ +import { TestBed } from '@angular/core/testing'; +import { DOCUMENT } from '@angular/common'; +import { PLATFORM_ID } from '@angular/core'; +import { + CpsRootFontSizeService, + CPS_ROOT_FONT_SIZE_SERVICE +} from './cps-root-font-size.service'; + +const SENTINEL_ATTR = 'data-cps-root-font-size-sentinel'; + +describe('CpsRootFontSizeService', () => { + let service: CpsRootFontSizeService; + let document: Document; + let resizeCallback: (entries: unknown[], observer: unknown) => void; + let mockObserve: jest.Mock; + let mockDisconnect: jest.Mock; + let computedFontSize: string; + + beforeEach(() => { + computedFontSize = '16px'; + mockObserve = jest.fn(); + mockDisconnect = jest.fn(); + + (globalThis as any).ResizeObserver = jest.fn( + (cb: (entries: unknown[], observer: unknown) => void) => { + resizeCallback = cb; + return { observe: mockObserve, disconnect: mockDisconnect }; + } + ); + + jest + .spyOn(window, 'getComputedStyle') + .mockReturnValue({ fontSize: computedFontSize } as CSSStyleDeclaration); + + TestBed.configureTestingModule({}); + service = TestBed.inject(CpsRootFontSizeService); + document = TestBed.inject(DOCUMENT); + }); + + afterEach(() => { + service.ngOnDestroy(); + jest.restoreAllMocks(); + delete (globalThis as any).ResizeObserver; + document + .querySelectorAll(`[${SENTINEL_ATTR}]`) + .forEach((el) => el.remove()); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should initialize fontSize from getComputedStyle', () => { + expect(service.fontSize()).toBe(16); + }); + + it('should append a sentinel element to the document root', () => { + const sentinel = document.querySelector(`[${SENTINEL_ATTR}]`); + expect(sentinel).not.toBeNull(); + expect(document.documentElement.contains(sentinel)).toBe(true); + }); + + it('should set sentinel style to width:1rem and be hidden', () => { + const sentinel = document.querySelector(`[${SENTINEL_ATTR}]`)!; + expect(sentinel.style.width).toBe('1rem'); + expect(sentinel.style.height).toBe('0px'); + expect(sentinel.style.visibility).toBe('hidden'); + expect(sentinel.style.position).toBe('absolute'); + expect(sentinel.style.pointerEvents).toBe('none'); + }); + + it('should start observing the sentinel element', () => { + expect(mockObserve).toHaveBeenCalledTimes(1); + const sentinel = document.querySelector(`[${SENTINEL_ATTR}]`); + expect(mockObserve).toHaveBeenCalledWith(sentinel); + }); + + it('should update fontSize signal when ResizeObserver fires with a new size', () => { + (window.getComputedStyle as jest.Mock).mockReturnValue({ + fontSize: '20px' + } as CSSStyleDeclaration); + + resizeCallback([], null as unknown as ResizeObserver); + + expect(service.fontSize()).toBe(20); + }); + + it('should not update fontSize signal when the size has not changed', () => { + resizeCallback([], null as unknown as ResizeObserver); + expect(service.fontSize()).toBe(16); + }); + + describe('ngOnDestroy', () => { + it('should disconnect the ResizeObserver', () => { + service.ngOnDestroy(); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + + it('should not remove the sentinel element on destroy', () => { + service.ngOnDestroy(); + expect(document.querySelector(`[${SENTINEL_ATTR}]`)).not.toBeNull(); + }); + + it('should null out internal references after destroy', () => { + service.ngOnDestroy(); + expect(() => service.ngOnDestroy()).not.toThrow(); + }); + }); + + describe('sentinel reuse (microfrontend scenario)', () => { + it('should reuse the existing sentinel when a second instance is created', () => { + const service2 = TestBed.runInInjectionContext( + () => new CpsRootFontSizeService() + ); + + expect(document.querySelectorAll(`[${SENTINEL_ATTR}]`).length).toBe(1); + + service2.ngOnDestroy(); + }); + + it('should keep the sentinel alive when the non-owning instance is destroyed', () => { + const service2 = TestBed.runInInjectionContext( + () => new CpsRootFontSizeService() + ); + + service2.ngOnDestroy(); + expect(document.querySelector(`[${SENTINEL_ATTR}]`)).not.toBeNull(); + }); + + it('should keep the sentinel alive when the owning (first) instance is destroyed while another is active', () => { + const service2 = TestBed.runInInjectionContext( + () => new CpsRootFontSizeService() + ); + + service.ngOnDestroy(); + expect(document.querySelector(`[${SENTINEL_ATTR}]`)).not.toBeNull(); + service2.ngOnDestroy(); + }); + + it('should keep tracking after the owning instance is destroyed: surviving instance still updates on resize', () => { + const callbacks: ((entries: unknown[], observer: unknown) => void)[] = []; + (globalThis as any).ResizeObserver = jest.fn( + (cb: (entries: unknown[], observer: unknown) => void) => { + callbacks.push(cb); + return { observe: mockObserve, disconnect: mockDisconnect }; + } + ); + + const service2 = TestBed.runInInjectionContext( + () => new CpsRootFontSizeService() + ); + + service.ngOnDestroy(); + + (window.getComputedStyle as jest.Mock).mockReturnValue({ + fontSize: '20px' + } as CSSStyleDeclaration); + + callbacks[0]([], null as unknown as ResizeObserver); + + expect(service2.fontSize()).toBe(20); + service2.ngOnDestroy(); + }); + + it('should keep the sentinel alive even after all instances are destroyed', () => { + const service2 = TestBed.runInInjectionContext( + () => new CpsRootFontSizeService() + ); + + service.ngOnDestroy(); + service2.ngOnDestroy(); + + expect(document.querySelector(`[${SENTINEL_ATTR}]`)).not.toBeNull(); + }); + }); +}); + +describe('CpsRootFontSizeService (SSR)', () => { + beforeEach(() => { + (globalThis as any).ResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + disconnect: jest.fn() + })); + jest + .spyOn(window, 'getComputedStyle') + .mockReturnValue({ fontSize: '16px' } as CSSStyleDeclaration); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete (globalThis as any).ResizeObserver; + }); + + it('should initialize fontSize to 16 in SSR', () => { + TestBed.configureTestingModule({ + providers: [{ provide: PLATFORM_ID, useValue: 'server' }] + }); + const service = TestBed.inject(CpsRootFontSizeService); + expect(service.fontSize()).toBe(16); + }); + + it('should not create a sentinel element in SSR', () => { + TestBed.configureTestingModule({ + providers: [{ provide: PLATFORM_ID, useValue: 'server' }] + }); + TestBed.inject(CpsRootFontSizeService); + const doc = TestBed.inject(DOCUMENT); + expect(doc.querySelector(`[${SENTINEL_ATTR}]`)).toBeNull(); + }); + + it('should not create a ResizeObserver in SSR', () => { + const observerCtor = (globalThis as any).ResizeObserver as jest.Mock; + TestBed.configureTestingModule({ + providers: [{ provide: PLATFORM_ID, useValue: 'server' }] + }); + TestBed.inject(CpsRootFontSizeService); + expect(observerCtor).not.toHaveBeenCalled(); + }); +}); + +describe('CPS_ROOT_FONT_SIZE_SERVICE token', () => { + beforeEach(() => { + (globalThis as any).ResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + disconnect: jest.fn() + })); + jest + .spyOn(window, 'getComputedStyle') + .mockReturnValue({ fontSize: '16px' } as CSSStyleDeclaration); + + TestBed.configureTestingModule({}); + }); + + afterEach(() => { + TestBed.inject(CpsRootFontSizeService).ngOnDestroy(); + jest.restoreAllMocks(); + delete (globalThis as any).ResizeObserver; + TestBed.inject(DOCUMENT) + .querySelectorAll(`[${SENTINEL_ATTR}]`) + .forEach((el) => el.remove()); + }); + + it('should resolve to the CpsRootFontSizeService singleton', () => { + const token = TestBed.inject(CPS_ROOT_FONT_SIZE_SERVICE); + const direct = TestBed.inject(CpsRootFontSizeService); + expect(token).toBe(direct); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/services/cps-root-font-size/cps-root-font-size.service.ts b/projects/cps-ui-kit/src/lib/services/cps-root-font-size/cps-root-font-size.service.ts new file mode 100644 index 00000000..721b28ef --- /dev/null +++ b/projects/cps-ui-kit/src/lib/services/cps-root-font-size/cps-root-font-size.service.ts @@ -0,0 +1,133 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { + inject, + Injectable, + InjectionToken, + OnDestroy, + PLATFORM_ID, + signal, + Signal +} from '@angular/core'; + +/** + * CpsRootFontSizeService tracks the application's current root font size. + * + * The service uses a ResizeObserver strategy to reliably detect root font-size changes: + * + * **Sentinel element** (`
`) — its pixel width + * mirrors `1rem`. Any root font-size change — caused by CSS class toggles, + * stylesheet rules, direct JS assignment, or viewport resize (e.g. + * `font-size: 1.5vw`) — changes the sentinel's computed width, firing the + * observer. + * The cached value is stored in a signal and is only updated when the actual + * font-size value changes, preventing spurious updates. + * + * In microfrontend environments the sentinel element is keyed by a known DOM + * attribute (`data-cps-root-font-size-sentinel`) and reused if already present, + * so only one sentinel node exists per document regardless of how many + * instances of this service are created. The sentinel is intentionally never + * removed from the DOM — it is a lightweight, invisible element and removing it + * could silently break any other live service instance still observing it. + * + * Only active in browser environments. Under SSR the `fontSize` signal is + * initialized to `16` (the standard browser default) and no DOM observers are created. + * + * Prefer injecting {@link CPS_ROOT_FONT_SIZE_SERVICE} over this class directly + * to allow consumer applications to override the behavior. + * + * @example + * ```typescript + * class MyComponent { + * private fontSizeService = inject(CPS_ROOT_FONT_SIZE_SERVICE); + * readonly fontSize = this.fontSizeService?.fontSize; + * } + * ``` + */ +@Injectable({ + providedIn: 'root' +}) +export class CpsRootFontSizeService implements OnDestroy { + private readonly _document = inject(DOCUMENT); + private readonly _platformId = inject(PLATFORM_ID); + + private static readonly _SENTINEL_ATTR = 'data-cps-root-font-size-sentinel'; + + private readonly _fontSize = signal( + isPlatformBrowser(this._platformId) ? this._readRootFontSize() : 16 + ); + + private _sentinelObserver: ResizeObserver | null = null; + + /** Reactive signal containing the current root font size in pixels. */ + readonly fontSize: Signal = this._fontSize.asReadonly(); + + constructor() { + if (!isPlatformBrowser(this._platformId)) return; + this._setupObservers(); + } + + ngOnDestroy(): void { + this._sentinelObserver?.disconnect(); + this._sentinelObserver = null; + } + + private _setupObservers(): void { + // Reuse an existing sentinel if another service instance already created one. + let sentinel = this._document.querySelector( + `[${CpsRootFontSizeService._SENTINEL_ATTR}]` + ); + + if (!sentinel) { + sentinel = this._document.createElement('div'); + sentinel.setAttribute(CpsRootFontSizeService._SENTINEL_ATTR, ''); + Object.assign(sentinel.style, { + position: 'absolute', + width: '1rem', + height: '0', + visibility: 'hidden', + pointerEvents: 'none', + userSelect: 'none', + top: '0', + left: '0' + }); + this._document.documentElement.appendChild(sentinel); + } + + this._sentinelObserver = new ResizeObserver(() => this._refresh()); + this._sentinelObserver.observe(sentinel); + } + + private _refresh(): void { + const newSize = this._readRootFontSize(); + if (newSize !== this._fontSize()) { + this._fontSize.set(newSize); + } + } + + private _readRootFontSize(): number { + return parseFloat( + getComputedStyle(this._document.documentElement).fontSize + ); + } +} + +/** + * Injection token for the root font size service. + * + * By default it resolves to the singleton {@link CpsRootFontSizeService}. + * 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_ROOT_FONT_SIZE_SERVICE, useValue: null } + * ] + * ``` + */ +export const CPS_ROOT_FONT_SIZE_SERVICE = + new InjectionToken('CpsRootFontSizeService', { + providedIn: 'root', + factory: () => inject(CpsRootFontSizeService) + }); diff --git a/projects/cps-ui-kit/src/lib/services/cps-theme/cps-theme.service.ts b/projects/cps-ui-kit/src/lib/services/cps-theme/cps-theme.service.ts index 5e186b50..514849be 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-theme/cps-theme.service.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-theme/cps-theme.service.ts @@ -284,13 +284,13 @@ export class CpsThemeService { } // TODO: Use as fallback in getInitialTheme() once dark mode is fully supported across all components. - private getSystemTheme(): CpsTheme { - const win = this.document.defaultView; - if (!win?.matchMedia) return 'light'; - return win.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light'; - } + // private getSystemTheme(): CpsTheme { + // const win = this.document.defaultView; + // if (!win?.matchMedia) return 'light'; + // return win.matchMedia('(prefers-color-scheme: dark)').matches + // ? 'dark' + // : 'light'; + // } // TODO: Enable system preference fallback once dark mode is fully supported across all components. private watchSystemTheme(): void { diff --git a/projects/cps-ui-kit/src/public-api.ts b/projects/cps-ui-kit/src/public-api.ts index 850e384e..797cb1af 100644 --- a/projects/cps-ui-kit/src/public-api.ts +++ b/projects/cps-ui-kit/src/public-api.ts @@ -59,6 +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/cps-root-font-size/cps-root-font-size.service'; export * from './lib/services/cps-focus/cps-focus.service'; export * from './lib/services/cps-theme/cps-theme.service'; export * from './lib/services/cps-cron-validation/cps-cron-validation.service'; diff --git a/tsconfig.json b/tsconfig.json index df168363..7500d996 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,8 @@ "outDir": "./dist/out-tsc", "forceConsistentCasingInFileNames": true, "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": false, "noImplicitReturns": true,