diff --git a/core/src/components/cat-icon/__mocks__/cat-icon-registry.ts b/core/src/components/cat-icon/__mocks__/cat-icon-registry.ts index ffd5baa40..bb0869d2b 100644 --- a/core/src/components/cat-icon/__mocks__/cat-icon-registry.ts +++ b/core/src/components/cat-icon/__mocks__/cat-icon-registry.ts @@ -3,6 +3,18 @@ export class CatIconRegistry { return new CatIconRegistry(); } + attachTo(_element: Element): () => void { + return () => {}; + } + + static createInstance(): CatIconRegistry { + return new CatIconRegistry(); + } + + hasIcon(_name: string, _setName?: string): boolean { + return false; + } + getIcon(_name: string, _setName?: string): string | undefined { return undefined; } diff --git a/core/src/components/cat-icon/cat-icon-registry.ts b/core/src/components/cat-icon/cat-icon-registry.ts index c125136bb..2b1369eb6 100644 --- a/core/src/components/cat-icon/cat-icon-registry.ts +++ b/core/src/components/cat-icon/cat-icon-registry.ts @@ -20,6 +20,7 @@ import eyeOpenOutlined from '@haiilo/catalyst-icons/src/eye-open-outlined.svg'; import infoCircleFilled from '@haiilo/catalyst-icons/src/info-circle-filled.svg'; import starCircleFilled from '@haiilo/catalyst-icons/src/star-circle-filled.svg'; import log from 'loglevel'; +import { CatIconRequestDetail } from './cat-icon-request'; export class CatIconRegistry { private static instance: CatIconRegistry; @@ -28,9 +29,10 @@ export class CatIconRegistry { private readonly icons: Map = new Map(); // ignore syncing in backwards compatible manner + // @deprecated: create isolated registry instance via createInstance to avoid conflicts between different icons versions syncIcons: boolean = true; - private constructor() { + private constructor(isScoped = false) { // hide constructor // register default icons that are used in the framework by other components @@ -69,19 +71,22 @@ export class CatIconRegistry { // one application from overwriting the registry in the other, we listen for // events that are dispatched when icons are added or removed in other // applications and add or remove icons if the event was not dispatched by - // this registry. - window.addEventListener('cat-icons-added', event => { - const { detail } = (event as CustomEvent) || {}; - if (this.syncIcons && detail && detail.id !== this.id) { - this.addIcons(detail.icons, detail.setName, true); - } - }); - window.addEventListener('cat-icons-removed', event => { - const { detail } = (event as CustomEvent) || {}; - if (this.syncIcons && detail && detail.id !== this.id) { - this.removeIcons(detail.names, detail.setName, true); - } - }); + // this registry. Scoped instances (created via createInstance()) do not + // participate in cross-registry syncing, so we skip these listeners for them. + if (!isScoped) { + window.addEventListener('cat-icons-added', event => { + const { detail } = (event as CustomEvent) || {}; + if (this.syncIcons && detail && detail.id !== this.id) { + this.addIcons(detail.icons, detail.setName, true); + } + }); + window.addEventListener('cat-icons-removed', event => { + const { detail } = (event as CustomEvent) || {}; + if (this.syncIcons && detail && detail.id !== this.id) { + this.removeIcons(detail.names, detail.setName, true); + } + }); + } } static getInstance(): CatIconRegistry { @@ -91,6 +96,80 @@ export class CatIconRegistry { return CatIconRegistry.instance; } + /** + * Creates a new isolated registry instance for use in micro frontends. + * + * Unlike the global singleton, this instance: + * - Does not sync icons with other registry instances via window events + * + * Use `attachTo` to scope icons to a DOM subtree without adding a wrapper + * element: + * + * ```ts + * // In your MFE bootstrap: + * const registry = CatIconRegistry.createInstance(); + * registry.addIcons(myIcons); + * const cleanup = registry.attachTo(mfeRootElement); + * // call cleanup() when the MFE unmounts + * ``` + */ + static createInstance(): CatIconRegistry { + return new CatIconRegistry(true); + } + + /** + * Attaches a `cat-icon-request` listener to `element`, making this registry + * the icon provider for all `cat-icon` descendants of that element. + * + * Resolution order: + * 1. This registry instance (scoped icons) + * 2. The global `catIconRegistry` singleton (framework defaults / host app + * icons) + * + * Returns a cleanup function that removes the listener. Call it when the + * element is removed from the DOM (e.g. MFE unmount/destroy/disconnect). + * + * ```ts + * const registry = CatIconRegistry.createInstance(); + * registry.addIcons(myIcons); + * const cleanup = registry.attachTo(document.querySelector('mfe-root')); + * // later… + * cleanup(); + * ``` + */ + attachTo(element: Element): () => void { + const handler = (e: Event) => { + const event = e as CustomEvent; + const { name, resolve } = event.detail; + let icon: string | undefined; + + // 1. This (scoped) registry + if (this.hasIcon(name)) { + icon = this.getIcon(name); + } + + // 2. Global registry fallback (framework defaults, host-app icons) + if (!icon && catIconRegistry.hasIcon(name)) { + icon = catIconRegistry.getIcon(name); + } + + if (!icon) { + return; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + resolve(icon); + }; + + element.addEventListener('cat-icon-request', handler); + return () => element.removeEventListener('cat-icon-request', handler); + } + + private hasIcon(name: string, setName?: string): boolean { + return this.icons.has(this.buildName(name, setName)); + } + getIcon(name: string, setName?: string): string | undefined { const icon = this.icons.get(this.buildName(name, setName)); if (!icon) { diff --git a/core/src/components/cat-icon/cat-icon-request.ts b/core/src/components/cat-icon/cat-icon-request.ts new file mode 100644 index 000000000..cadb87e74 --- /dev/null +++ b/core/src/components/cat-icon/cat-icon-request.ts @@ -0,0 +1,15 @@ +/** + * Detail payload for the `cat-icon-request` custom event. + * + * `cat-icon` dispatches this event (bubbling, composed) to let an + * ancestor CatIconRegistry instance resolve the SVG for the requested icon name. + * The provider calls `resolve(svg)` synchronously and calls `preventDefault()` + * to signal that the request was handled. If no provider cancels the event, + * `cat-icon` falls back to the global `catIconRegistry`. + */ +export interface CatIconRequestDetail { + /** The icon name as passed to the `icon` prop of `cat-icon`. */ + readonly name: string; + /** Called by the nearest CatIconRegistry instance with the resolved SVG string. */ + resolve(svg: string): void; +} diff --git a/core/src/components/cat-icon/cat-icon.spec.tsx b/core/src/components/cat-icon/cat-icon.spec.tsx index 8f15cc5f2..1cd343bae 100644 --- a/core/src/components/cat-icon/cat-icon.spec.tsx +++ b/core/src/components/cat-icon/cat-icon.spec.tsx @@ -11,10 +11,41 @@ vi.mock('./cat-icon-registry', () => ({ import './cat-icon'; describe('cat-icon', () => { - it('renders', async () => { + it('renders without an icon', async () => { + const { root } = await render(); + expect(root.shadowRoot).toEqualLightHtml(` + + `); + }); + + it('renders with an icon name (falls back to global registry when no provider)', async () => { const { root } = await render(); - await expect(root.shadowRoot).toEqualHtml(` + // The mock registry returns undefined, so innerHTML is empty + expect(root.shadowRoot).toEqualLightHtml(` `); }); + + it('renders with iconSrc, bypassing registry entirely', async () => { + const { root } = await render(); + // JSDOM normalises self-closing SVG tags to + expect(root.shadowRoot?.querySelector('span')?.innerHTML).toContain('svg'); + }); + + it('dispatches a cat-icon-request event when an icon name is set', async () => { + const { root, waitForChanges } = await render(); + const events: CustomEvent[] = []; + const handler = (e: Event) => events.push(e as CustomEvent); + document.body.addEventListener('cat-icon-request', handler); + + root.setAttribute('icon', 'home'); + await waitForChanges(); + + const iconEvents = events.filter(e => e.detail?.name === 'home'); + expect(iconEvents.length).toBeGreaterThan(0); + expect(iconEvents[0].cancelable).toBe(true); + expect(iconEvents[0].bubbles).toBe(true); + + document.body.removeEventListener('cat-icon-request', handler); + }); }); diff --git a/core/src/components/cat-icon/cat-icon.tsx b/core/src/components/cat-icon/cat-icon.tsx index 4256b7c49..c302ce366 100644 --- a/core/src/components/cat-icon/cat-icon.tsx +++ b/core/src/components/cat-icon/cat-icon.tsx @@ -1,4 +1,5 @@ -import { Component, h, Prop } from '@stencil/core'; +import { Component, Element, h, Prop, State, Watch } from '@stencil/core'; +import { CatIconRequestDetail } from './cat-icon-request'; import { catIconRegistry as icons } from './cat-icon-registry'; /** @@ -13,6 +14,10 @@ import { catIconRegistry as icons } from './cat-icon-registry'; shadow: true }) export class CatIcon { + @Element() el!: HTMLElement; + + @State() private resolvedSvg?: string; + /** * The name of the icon. */ @@ -34,10 +39,42 @@ export class CatIcon { */ @Prop({ attribute: 'a11y-label' }) a11yLabel?: string; + componentWillLoad() { + this.resolveIcon(); + } + + @Watch('icon') + @Watch('iconSrc') + resolveIcon() { + if (this.iconSrc || !this.icon) { + this.resolvedSvg = undefined; + return; + } + + const event = new CustomEvent('cat-icon-request', { + bubbles: true, + cancelable: true, + composed: true, + detail: { + name: this.icon, + resolve: (svg: string) => { + this.resolvedSvg = svg; + } + } + }); + const notCancelled = this.el.dispatchEvent(event); + + if (notCancelled) { + // No cat-icon-registry instance in the ancestry — use the global registry directly + // (preserves the pre-existing behavior for apps that don't use providers). + this.resolvedSvg = icons.getIcon(this.icon); + } + } + render() { return ( =21.0.0' + version: 21.1.0(@angular/common@21.2.0(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.0(@angular/animations@21.2.0(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.0(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) + '@angular/core': + specifier: '>=21.0.0' + version: 21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.15.1) + '@haiilo/catalyst': + specifier: workspace:* + version: link:../../../core + '@haiilo/catalyst-tokens': + specifier: workspace:* + version: link:../../../tokens + loglevel: + specifier: 1.8.1 + version: 1.8.1 + rxjs: + specifier: ^6.5.3 || ^7.4.0 + version: 7.8.2 + tslib: + specifier: ^2.3.0 + version: 2.8.1 + + angular/dist/catalyst-formly: + dependencies: + '@angular/core': + specifier: '>=21.0.0' + version: 21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.15.1) + '@haiilo/catalyst-angular': + specifier: workspace:* + version: link:../../projects/catalyst + '@ngx-formly/core': + specifier: ^7.0.0 + version: 7.0.1(@angular/forms@21.2.0(@angular/common@21.2.0(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.0(@angular/animations@21.2.0(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.0(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.0(@angular/compiler@21.2.0)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(rxjs@7.8.2) + tslib: + specifier: ^2.3.0 + version: 2.8.1 + angular/projects/catalyst: dependencies: '@angular/cdk':