diff --git a/api-generator/api-generator.js b/api-generator/api-generator.js index 35ca2301..31daad8c 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 moduleDoc = mergedDocs[key]; + const isEmpty = + Object.keys(moduleDoc).length === 1 && + moduleDoc.components && + Object.keys(moduleDoc.components).length === 0; + if (isEmpty) continue; + const typedocJSON = JSON.stringify(moduleDoc, null, 4); !fs.existsSync(outputPath) && fs.mkdirSync(outputPath); fs.writeFileSync(path.resolve(outputPath, `${key}.json`), typedocJSON); } 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..28c4dc75 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/services/cps-root-font-size/cps-root-font-size.service.ts @@ -0,0 +1,137 @@ +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 _sentinel: HTMLElement | null = null; + 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; + this._sentinel = 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._sentinel = 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/public-api.ts b/projects/cps-ui-kit/src/public-api.ts index 97b6370c..448b5a7f 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-root-font-size/cps-root-font-size.service'; export * from './lib/services/cps-theme/cps-theme.service'; export * from './lib/utils/colors-utils';