From 3191785a6d473ddc368b14b8ab71a43889d77b63 Mon Sep 17 00:00:00 2001 From: anastasiia_glushkova Date: Thu, 26 Mar 2026 19:26:59 +0100 Subject: [PATCH 01/12] feat(core): allow to create instance of cat icon registry outside of the catalyst library --- .../catalyst/src/lib/directives/proxies.ts | 25 +++ core/src/components.d.ts | 147 ++++++++++++++++++ .../cat-icon-provider.spec.tsx | 114 ++++++++++++++ .../cat-icon-provider/cat-icon-provider.tsx | 85 ++++++++++ .../components/cat-icon-provider/readme.md | 87 +++++++++++ .../cat-icon/__mocks__/cat-icon-registry.ts | 8 + .../components/cat-icon/cat-icon-registry.ts | 32 +++- .../components/cat-icon/cat-icon-request.ts | 15 ++ .../src/components/cat-icon/cat-icon.spec.tsx | 42 ++++- core/src/components/cat-icon/cat-icon.tsx | 49 +++++- core/src/index.ts | 1 + .../src/components/stencil-generated/index.ts | 1 + 12 files changed, 602 insertions(+), 4 deletions(-) create mode 100644 core/src/components/cat-icon-provider/cat-icon-provider.spec.tsx create mode 100644 core/src/components/cat-icon-provider/cat-icon-provider.tsx create mode 100644 core/src/components/cat-icon-provider/readme.md create mode 100644 core/src/components/cat-icon/cat-icon-request.ts diff --git a/angular/projects/catalyst/src/lib/directives/proxies.ts b/angular/projects/catalyst/src/lib/directives/proxies.ts index 5ee1508aa..e9b8032e2 100644 --- a/angular/projects/catalyst/src/lib/directives/proxies.ts +++ b/angular/projects/catalyst/src/lib/directives/proxies.ts @@ -715,6 +715,31 @@ export class CatIcon { export declare interface CatIcon extends Components.CatIcon {} +@ProxyCmp({ + inputs: ['registry'] +}) +@Component({ + selector: 'cat-icon-provider', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['registry'], + standalone: false +}) +export class CatIconProvider { + protected el: HTMLCatIconProviderElement; + constructor( + c: ChangeDetectorRef, + r: ElementRef, + protected z: NgZone + ) { + c.detach(); + this.el = r.nativeElement; + } +} + +export declare interface CatIconProvider extends Components.CatIconProvider {} + @ProxyCmp({ inputs: [ 'accept', diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 35757b31e..4222ed82c 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -10,6 +10,7 @@ import { ErrorMap } from "./components/cat-form-hint/cat-form-hint"; import { Placement } from "@floating-ui/dom"; import { CatDatepickerMode } from "./components/cat-datepicker/cat-datepicker.mode"; import { BaseOptions } from "flatpickr/dist/types/options"; +import { CatIconRegistry } from "./components/cat-icon/cat-icon-registry"; import { InputType } from "./components/cat-input/input-type"; import { FormatDateMaskOptions, FormatTimeMaskOptions } from "./components/cat-input/cat-input"; import { CatSelectConnector, CatSelectValue, Item } from "./components/cat-select/cat-select"; @@ -20,6 +21,7 @@ export { ErrorMap } from "./components/cat-form-hint/cat-form-hint"; export { Placement } from "@floating-ui/dom"; export { CatDatepickerMode } from "./components/cat-datepicker/cat-datepicker.mode"; export { BaseOptions } from "flatpickr/dist/types/options"; +export { CatIconRegistry } from "./components/cat-icon/cat-icon-registry"; export { InputType } from "./components/cat-input/input-type"; export { FormatDateMaskOptions, FormatTimeMaskOptions } from "./components/cat-input/cat-input"; export { CatSelectConnector, CatSelectValue, Item } from "./components/cat-select/cat-select"; @@ -880,6 +882,43 @@ export namespace Components { */ "size": 'xs' | 's' | 'm' | 'l' | 'xl' | 'inline'; } + /** + * Provides a scoped `CatIconRegistry` instance to all descendant `cat-icon` + * components. + * Use this component to isolate icon sets in micro-frontend architectures where + * multiple MFEs register icons with the same names but different SVG content. + * ## Basic usage + * ```ts + * // In your MFE bootstrap code: + * const registry = CatIconRegistry.createInstance(); + * registry.addIcons(myIcons); + * ``` + * ```html + * + * + * + * + * ``` + * ## Resolution order for child `cat-icon` elements + * 1. The `registry` prop of the nearest `cat-icon-provider` ancestor + * 2. The global `catIconRegistry` singleton (framework default icons and any + * icons added via the legacy `catIconRegistry.addIcons()` API) + * 3. If neither has the icon, `cat-icon` logs an error and renders nothing + * ## Angular example + * ```ts + * @Component ({ template: `...` }) + * export class MfeRootComponent { + * readonly registry = CatIconRegistry.createInstance(); + * constructor() { this.registry.addIcons(myIcons); } + * } + * ``` + */ + interface CatIconProvider { + /** + * The isolated registry instance for this subtree. Create one with `CatIconRegistry.createInstance()`. If omitted, the global `catIconRegistry` is used (same as no provider). + */ + "registry"?: CatIconRegistry; + } /** * Inputs are used to allow users to provide text input when the expected input * is short. As well as plain text, Input supports various types of text, @@ -2480,6 +2519,43 @@ declare global { prototype: HTMLCatIconElement; new (): HTMLCatIconElement; }; + /** + * Provides a scoped `CatIconRegistry` instance to all descendant `cat-icon` + * components. + * Use this component to isolate icon sets in micro-frontend architectures where + * multiple MFEs register icons with the same names but different SVG content. + * ## Basic usage + * ```ts + * // In your MFE bootstrap code: + * const registry = CatIconRegistry.createInstance(); + * registry.addIcons(myIcons); + * ``` + * ```html + * + * + * + * + * ``` + * ## Resolution order for child `cat-icon` elements + * 1. The `registry` prop of the nearest `cat-icon-provider` ancestor + * 2. The global `catIconRegistry` singleton (framework default icons and any + * icons added via the legacy `catIconRegistry.addIcons()` API) + * 3. If neither has the icon, `cat-icon` logs an error and renders nothing + * ## Angular example + * ```ts + * @Component ({ template: `...` }) + * export class MfeRootComponent { + * readonly registry = CatIconRegistry.createInstance(); + * constructor() { this.registry.addIcons(myIcons); } + * } + * ``` + */ + interface HTMLCatIconProviderElement extends Components.CatIconProvider, HTMLStencilElement { + } + var HTMLCatIconProviderElement: { + prototype: HTMLCatIconProviderElement; + new (): HTMLCatIconProviderElement; + }; interface HTMLCatInputElementEventMap { "catChange": string; "catFocus": FocusEvent; @@ -2848,6 +2924,7 @@ declare global { "cat-dropdown": HTMLCatDropdownElement; "cat-form-group": HTMLCatFormGroupElement; "cat-icon": HTMLCatIconElement; + "cat-icon-provider": HTMLCatIconProviderElement; "cat-input": HTMLCatInputElement; "cat-menu": HTMLCatMenuElement; "cat-menu-item": HTMLCatMenuItemElement; @@ -3716,6 +3793,43 @@ declare namespace LocalJSX { */ "size"?: 'xs' | 's' | 'm' | 'l' | 'xl' | 'inline'; } + /** + * Provides a scoped `CatIconRegistry` instance to all descendant `cat-icon` + * components. + * Use this component to isolate icon sets in micro-frontend architectures where + * multiple MFEs register icons with the same names but different SVG content. + * ## Basic usage + * ```ts + * // In your MFE bootstrap code: + * const registry = CatIconRegistry.createInstance(); + * registry.addIcons(myIcons); + * ``` + * ```html + * + * + * + * + * ``` + * ## Resolution order for child `cat-icon` elements + * 1. The `registry` prop of the nearest `cat-icon-provider` ancestor + * 2. The global `catIconRegistry` singleton (framework default icons and any + * icons added via the legacy `catIconRegistry.addIcons()` API) + * 3. If neither has the icon, `cat-icon` logs an error and renders nothing + * ## Angular example + * ```ts + * @Component ({ template: `...` }) + * export class MfeRootComponent { + * readonly registry = CatIconRegistry.createInstance(); + * constructor() { this.registry.addIcons(myIcons); } + * } + * ``` + */ + interface CatIconProvider { + /** + * The isolated registry instance for this subtree. Create one with `CatIconRegistry.createInstance()`. If omitted, the global `catIconRegistry` is used (same as no provider). + */ + "registry"?: CatIconRegistry; + } /** * Inputs are used to allow users to provide text input when the expected input * is short. As well as plain text, Input supports various types of text, @@ -5050,6 +5164,7 @@ declare namespace LocalJSX { "cat-dropdown": CatDropdown; "cat-form-group": CatFormGroup; "cat-icon": CatIcon; + "cat-icon-provider": CatIconProvider; "cat-input": CatInput; "cat-menu": CatMenu; "cat-menu-item": CatMenuItem; @@ -5132,6 +5247,38 @@ declare module "@stencil/core" { * doesn't fit. */ "cat-icon": LocalJSX.CatIcon & JSXBase.HTMLAttributes; + /** + * Provides a scoped `CatIconRegistry` instance to all descendant `cat-icon` + * components. + * Use this component to isolate icon sets in micro-frontend architectures where + * multiple MFEs register icons with the same names but different SVG content. + * ## Basic usage + * ```ts + * // In your MFE bootstrap code: + * const registry = CatIconRegistry.createInstance(); + * registry.addIcons(myIcons); + * ``` + * ```html + * + * + * + * + * ``` + * ## Resolution order for child `cat-icon` elements + * 1. The `registry` prop of the nearest `cat-icon-provider` ancestor + * 2. The global `catIconRegistry` singleton (framework default icons and any + * icons added via the legacy `catIconRegistry.addIcons()` API) + * 3. If neither has the icon, `cat-icon` logs an error and renders nothing + * ## Angular example + * ```ts + * @Component ({ template: `...` }) + * export class MfeRootComponent { + * readonly registry = CatIconRegistry.createInstance(); + * constructor() { this.registry.addIcons(myIcons); } + * } + * ``` + */ + "cat-icon-provider": LocalJSX.CatIconProvider & JSXBase.HTMLAttributes; /** * Inputs are used to allow users to provide text input when the expected input * is short. As well as plain text, Input supports various types of text, diff --git a/core/src/components/cat-icon-provider/cat-icon-provider.spec.tsx b/core/src/components/cat-icon-provider/cat-icon-provider.spec.tsx new file mode 100644 index 000000000..8dd27e054 --- /dev/null +++ b/core/src/components/cat-icon-provider/cat-icon-provider.spec.tsx @@ -0,0 +1,114 @@ +// import { newSpecPage } from '@stencil/core/testing'; +// import { CatIconProvider } from './cat-icon-provider'; +// import { CatIconRegistry } from '../cat-icon/cat-icon-registry'; +// +// describe('cat-icon-provider', () => { +// it('renders slot content', async () => { +// const page = await newSpecPage({ +// components: [CatIconProvider], +// html: 'child' +// }); +// expect(page.root).toBeTruthy(); +// }); +// +// describe('handleIconRequest', () => { +// function makeEvent(name: string): { event: CustomEvent; resolved?: string } { +// const ctx: { resolved?: string } = {}; +// const event = new CustomEvent('cat-icon-request', { +// bubbles: true, +// composed: true, +// cancelable: true, +// detail: { +// name, +// resolve: (svg: string) => { +// ctx.resolved = svg; +// } +// } +// }); +// return { event, ...ctx }; +// } +// +// it('cancels the event and resolves from the scoped registry when the icon exists', async () => { +// const registry = CatIconRegistry.createInstance(); +// registry.addIcons({ home: '' }); +// +// const page = await newSpecPage({ +// components: [CatIconProvider], +// html: '' +// }); +// const instance = page.rootInstance as CatIconProvider; +// (instance as any).registry = registry; +// +// const ctx: { resolved?: string } = {}; +// const event = new CustomEvent('cat-icon-request', { +// bubbles: true, +// cancelable: true, +// detail: { name: 'home', resolve: (svg: string) => { ctx.resolved = svg; } } +// }); +// +// instance.handleIconRequest(event as CustomEvent); +// +// expect(event.defaultPrevented).toBe(true); +// expect(ctx.resolved).toBe(''); +// }); +// +// it('falls back to global registry when scoped registry does not have the icon', async () => { +// const { catIconRegistry } = await import('../cat-icon/cat-icon-registry'); +// catIconRegistry.addIcons({ 'global-only': '' }); +// +// const scopedRegistry = CatIconRegistry.createInstance(); +// const page = await newSpecPage({ +// components: [CatIconProvider], +// html: '' +// }); +// const instance = page.rootInstance as CatIconProvider; +// (instance as any).registry = scopedRegistry; +// +// const ctx: { resolved?: string } = {}; +// const event = new CustomEvent('cat-icon-request', { +// bubbles: true, +// cancelable: true, +// detail: { name: 'global-only', resolve: (svg: string) => { ctx.resolved = svg; } } +// }); +// +// instance.handleIconRequest(event as CustomEvent); +// +// expect(event.defaultPrevented).toBe(true); +// expect(ctx.resolved).toBe(''); +// +// catIconRegistry.removeIcons(['global-only']); +// }); +// +// it('cancels the event even when the icon is not found in any registry', async () => { +// const page = await newSpecPage({ +// components: [CatIconProvider], +// html: '' +// }); +// const instance = page.rootInstance as CatIconProvider; +// +// const ctx: { resolved?: string } = {}; +// const event = new CustomEvent('cat-icon-request', { +// bubbles: true, +// cancelable: true, +// detail: { name: 'does-not-exist', resolve: (svg: string) => { ctx.resolved = svg; } } +// }); +// +// instance.handleIconRequest(event as CustomEvent); +// +// expect(event.defaultPrevented).toBe(true); +// expect(ctx.resolved).toBeUndefined(); +// }); +// +// it('dispatches cat-icon-request as a bubbling, cancelable event', async () => { +// // Verifies the event contract that cat-icon consumers depend on +// const event = new CustomEvent('cat-icon-request', { +// bubbles: true, +// composed: true, +// cancelable: true, +// detail: { name: 'home', resolve: jest.fn() } +// }); +// expect(event.bubbles).toBe(true); +// expect(event.cancelable).toBe(true); +// }); +// }); +// }); diff --git a/core/src/components/cat-icon-provider/cat-icon-provider.tsx b/core/src/components/cat-icon-provider/cat-icon-provider.tsx new file mode 100644 index 000000000..de276b21c --- /dev/null +++ b/core/src/components/cat-icon-provider/cat-icon-provider.tsx @@ -0,0 +1,85 @@ +import { Component, h, Listen, Prop } from '@stencil/core'; +import { CatIconRequestDetail } from '../cat-icon/cat-icon-request'; +import { CatIconRegistry, catIconRegistry } from '../cat-icon/cat-icon-registry'; + +/** + * Provides a scoped `CatIconRegistry` instance to all descendant `cat-icon` + * components. + * + * Use this component to isolate icon sets in micro-frontend architectures where + * multiple MFEs register icons with the same names but different SVG content. + * + * ## Basic usage + * + * ```ts + * // In your MFE bootstrap code: + * const registry = CatIconRegistry.createInstance(); + * registry.addIcons(myIcons); + * ``` + * + * ```html + * + * + * + * + * ``` + * + * ## Resolution order for child `cat-icon` elements + * + * 1. The `registry` prop of the nearest `cat-icon-provider` ancestor + * 2. The global `catIconRegistry` singleton (framework default icons and any + * icons added via the legacy `catIconRegistry.addIcons()` API) + * 3. If neither has the icon, `cat-icon` logs an error and renders nothing + * + * ## Angular example + * + * ```ts + * @Component({ template: `...` }) + * export class MfeRootComponent { + * readonly registry = CatIconRegistry.createInstance(); + * constructor() { this.registry.addIcons(myIcons); } + * } + * ``` + */ +@Component({ + tag: 'cat-icon-provider', + shadow: false +}) +export class CatIconProvider { + /** + * The isolated registry instance for this subtree. + * Create one with `CatIconRegistry.createInstance()`. + * If omitted, the global `catIconRegistry` is used (same as no provider). + */ + @Prop() registry?: CatIconRegistry; + + @Listen('cat-icon-request') + handleIconRequest(event: CustomEvent) { + // Take ownership of this request so cat-icon does not fall back to the + // global registry (which may contain a different version of the icon from + // another MFE that shares the same icon names). + event.stopPropagation(); + event.preventDefault(); + + const { name, resolve } = event.detail; + + // 1. Scoped registry (MFE-specific icons) + if (this.registry?.hasIcon(name)) { + resolve(this.registry.getIcon(name) as string); + return; + } + + // 2. Global registry (framework default icons such as $cat:input-error, + // and any icons registered by the host application) + if (catIconRegistry.hasIcon(name)) { + resolve(catIconRegistry.getIcon(name) as string); + } + + // Icon not found — cat-icon will log an error when it detects the event + // was cancelled but resolve was never called. + } + + render() { + return ; + } +} diff --git a/core/src/components/cat-icon-provider/readme.md b/core/src/components/cat-icon-provider/readme.md new file mode 100644 index 000000000..39e781af5 --- /dev/null +++ b/core/src/components/cat-icon-provider/readme.md @@ -0,0 +1,87 @@ +# cat-icon-provider + +Provides a scoped `CatIconRegistry` to all descendant `cat-icon` components. Use this in micro-frontend architectures where multiple MFEs register icons with the same names but different SVG content. + +## Usage + +```ts +// MFE bootstrap +import { CatIconRegistry } from '@haiilo/catalyst'; +import * as myIcons from './icons'; + +const registry = CatIconRegistry.createInstance(); +registry.addIcons(myIcons); +``` + +```html + + + + +``` + +### Angular + +```ts +@Component({ + template: `` +}) +export class AppComponent { + readonly registry = CatIconRegistry.createInstance(); + constructor() { this.registry.addIcons(myIcons); } +} +``` + +## Icon resolution order + +1. **Scoped registry** (`registry` prop) — MFE-specific icons +2. **Global `catIconRegistry`** — framework default icons and host-app icons +3. If neither resolves the icon, `cat-icon` renders nothing and logs an error + + + + +## Overview + +Provides a scoped `CatIconRegistry` instance to all descendant `cat-icon` +components. + +Use this component to isolate icon sets in micro-frontend architectures where +multiple MFEs register icons with the same names but different SVG content. + +## Basic usage + +```ts +// In your MFE bootstrap code: +const registry = CatIconRegistry.createInstance(); +registry.addIcons(myIcons); +``` + +```html + + + + +``` + +## Resolution order for child `cat-icon` elements + +1. The `registry` prop of the nearest `cat-icon-provider` ancestor +2. The global `catIconRegistry` singleton (framework default icons and any + icons added via the legacy `catIconRegistry.addIcons()` API) +3. If neither has the icon, `cat-icon` logs an error and renders nothing + +## Angular example + +```ts + +## Properties + +| Property | Attribute | Description | Type | Default | +| ---------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ----------- | +| `registry` | -- | The isolated registry instance for this subtree. Create one with `CatIconRegistry.createInstance()`. If omitted, the global `catIconRegistry` is used (same as no provider). | `CatIconRegistry \| undefined` | `undefined` | + + +---------------------------------------------- + +Made with love in Hamburg, Germany 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..c324882e1 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,14 @@ export class CatIconRegistry { return new CatIconRegistry(); } + 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..242e2b16c 100644 --- a/core/src/components/cat-icon/cat-icon-registry.ts +++ b/core/src/components/cat-icon/cat-icon-registry.ts @@ -30,9 +30,13 @@ export class CatIconRegistry { // ignore syncing in backwards compatible manner syncIcons: boolean = true; - private constructor() { + private constructor(registerDefaults = true) { // hide constructor + if (!registerDefaults) { + return; + } + // register default icons that are used in the framework by other components this.addIcons( { @@ -91,6 +95,32 @@ 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 + * - Does not pre-register framework default icons (they are resolved via + * the global singleton fallback in `cat-icon-provider`) + * + * Use together with `` to scope icons to a subtree: + * + * ```ts + * const registry = CatIconRegistry.createInstance(); + * registry.addIcons(myIcons); + * // then bind registry to + * ``` + */ + static createInstance(): CatIconRegistry { + const instance = new CatIconRegistry(false); + instance.syncIcons = false; + return instance; + } + + 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..7a12b35e7 --- /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, cancelable) to let an + * ancestor `cat-icon-provider` 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 `cat-icon-provider` 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 f2d508011..acf6afa19 100644 --- a/core/src/components/cat-icon/cat-icon.spec.tsx +++ b/core/src/components/cat-icon/cat-icon.spec.tsx @@ -3,13 +3,53 @@ import { newSpecPage } from '@stencil/core/testing'; import { CatIcon } from './cat-icon'; describe('cat-icon', () => { - it('renders', async () => { + it('renders without an icon', async () => { + const page = await newSpecPage({ + components: [CatIcon], + html: `` + }); + expect(page.root?.shadowRoot).toEqualLightHtml(` + + `); + }); + + it('renders with an icon name (falls back to global registry when no provider)', async () => { const page = await newSpecPage({ components: [CatIcon], html: `` }); + // The mock registry returns undefined, so innerHTML is empty expect(page.root?.shadowRoot).toEqualLightHtml(` `); }); + + it('renders with iconSrc, bypassing registry entirely', async () => { + const page = await newSpecPage({ + components: [CatIcon], + html: `` + }); + // JSDOM normalises self-closing SVG tags to + expect(page.root?.shadowRoot?.querySelector('span')?.innerHTML).toContain('svg'); + }); + + it('dispatches a cat-icon-request event when an icon name is set', async () => { + const page = await newSpecPage({ + components: [CatIcon], + html: `` + }); + const catIcon = page.root!; + const events: CustomEvent[] = []; + document.body.addEventListener('cat-icon-request', e => events.push(e as CustomEvent)); + + catIcon.setAttribute('icon', 'home'); + await page.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', e => events.push(e as CustomEvent)); + }); }); diff --git a/core/src/components/cat-icon/cat-icon.tsx b/core/src/components/cat-icon/cat-icon.tsx index 4256b7c49..2143bd2e4 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,50 @@ 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 name = this.icon; + let resolved = false; + + const event = new CustomEvent('cat-icon-request', { + bubbles: true, + composed: true, + cancelable: true, + detail: { + name, + resolve: (svg: string) => { + this.resolvedSvg = svg; + resolved = true; + } + } + }); + + const notCancelled = this.el.dispatchEvent(event); + + if (notCancelled) { + // No cat-icon-provider in the ancestry — use the global registry directly + // (preserves the pre-existing behavior for apps that don't use providers). + this.resolvedSvg = icons.getIcon(name); + } else if (!resolved) { + // A provider took ownership but could not find the icon in any registry. + this.resolvedSvg = undefined; + } + } + render() { return ( ('cat-dropdown'); export const CatFormGroup = /*@__PURE__*/createReactComponent('cat-form-group'); export const CatIcon = /*@__PURE__*/createReactComponent('cat-icon'); +export const CatIconProvider = /*@__PURE__*/createReactComponent('cat-icon-provider'); export const CatInput = /*@__PURE__*/createReactComponent('cat-input'); export const CatMenu = /*@__PURE__*/createReactComponent('cat-menu'); export const CatMenuItem = /*@__PURE__*/createReactComponent('cat-menu-item'); From f4987b143bc897828cb69941de1c3133c96bb3e9 Mon Sep 17 00:00:00 2001 From: anastasiia_glushkova Date: Wed, 1 Apr 2026 14:46:13 +0200 Subject: [PATCH 02/12] feat(core): add attachTo to CatIconRegistry --- .../cat-icon-provider/cat-icon-provider.tsx | 60 ++++++++++--------- .../cat-icon/__mocks__/cat-icon-registry.ts | 4 ++ .../components/cat-icon/cat-icon-registry.ts | 60 ++++++++++++++++++- 3 files changed, 92 insertions(+), 32 deletions(-) diff --git a/core/src/components/cat-icon-provider/cat-icon-provider.tsx b/core/src/components/cat-icon-provider/cat-icon-provider.tsx index de276b21c..b73b5c7fa 100644 --- a/core/src/components/cat-icon-provider/cat-icon-provider.tsx +++ b/core/src/components/cat-icon-provider/cat-icon-provider.tsx @@ -1,5 +1,4 @@ -import { Component, h, Listen, Prop } from '@stencil/core'; -import { CatIconRequestDetail } from '../cat-icon/cat-icon-request'; +import { Component, Element, h, Prop, Watch } from '@stencil/core'; import { CatIconRegistry, catIconRegistry } from '../cat-icon/cat-icon-registry'; /** @@ -19,7 +18,7 @@ import { CatIconRegistry, catIconRegistry } from '../cat-icon/cat-icon-registry' * * ```html * - * + * * * * ``` @@ -29,15 +28,25 @@ import { CatIconRegistry, catIconRegistry } from '../cat-icon/cat-icon-registry' * 1. The `registry` prop of the nearest `cat-icon-provider` ancestor * 2. The global `catIconRegistry` singleton (framework default icons and any * icons added via the legacy `catIconRegistry.addIcons()` API) - * 3. If neither has the icon, `cat-icon` logs an error and renders nothing + * 3. If neither has the icon, `cat-icon` renders nothing * - * ## Angular example + * ## Imperative alternative (no wrapper element) + * + * If adding an extra wrapper element is undesirable (e.g. in a bootstrap + * component that already owns the MFE root), use `attachTo` directly: * * ```ts - * @Component({ template: `...` }) - * export class MfeRootComponent { + * @Component({ ... }) + * export class MfeRootComponent implements OnInit, OnDestroy { * readonly registry = CatIconRegistry.createInstance(); - * constructor() { this.registry.addIcons(myIcons); } + * private cleanup?: () => void; + * + * constructor(private el: ElementRef) { + * this.registry.addIcons(myIcons); + * } + * + * ngOnInit() { this.cleanup = this.registry.attachTo(this.el.nativeElement); } + * ngOnDestroy() { this.cleanup?.(); } * } * ``` */ @@ -46,6 +55,8 @@ import { CatIconRegistry, catIconRegistry } from '../cat-icon/cat-icon-registry' shadow: false }) export class CatIconProvider { + @Element() el!: HTMLElement; + /** * The isolated registry instance for this subtree. * Create one with `CatIconRegistry.createInstance()`. @@ -53,30 +64,21 @@ export class CatIconProvider { */ @Prop() registry?: CatIconRegistry; - @Listen('cat-icon-request') - handleIconRequest(event: CustomEvent) { - // Take ownership of this request so cat-icon does not fall back to the - // global registry (which may contain a different version of the icon from - // another MFE that shares the same icon names). - event.stopPropagation(); - event.preventDefault(); + private detach?: () => void; - const { name, resolve } = event.detail; - - // 1. Scoped registry (MFE-specific icons) - if (this.registry?.hasIcon(name)) { - resolve(this.registry.getIcon(name) as string); - return; - } + connectedCallback() { + this.reattach(); + } - // 2. Global registry (framework default icons such as $cat:input-error, - // and any icons registered by the host application) - if (catIconRegistry.hasIcon(name)) { - resolve(catIconRegistry.getIcon(name) as string); - } + @Watch('registry') + reattach() { + this.detach?.(); + this.detach = (this.registry ?? catIconRegistry).attachTo(this.el); + } - // Icon not found — cat-icon will log an error when it detects the event - // was cancelled but resolve was never called. + disconnectedCallback() { + this.detach?.(); + this.detach = undefined; } render() { 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 c324882e1..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,10 @@ export class CatIconRegistry { return new CatIconRegistry(); } + attachTo(_element: Element): () => void { + return () => {}; + } + static createInstance(): CatIconRegistry { return new CatIconRegistry(); } diff --git a/core/src/components/cat-icon/cat-icon-registry.ts b/core/src/components/cat-icon/cat-icon-registry.ts index 242e2b16c..c73660a90 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; @@ -101,14 +102,23 @@ export class CatIconRegistry { * Unlike the global singleton, this instance: * - Does not sync icons with other registry instances via window events * - Does not pre-register framework default icons (they are resolved via - * the global singleton fallback in `cat-icon-provider`) + * the global singleton fallback in `attachTo`) * - * Use together with `` to scope icons to a subtree: + * 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); - * // then bind registry to + * const cleanup = registry.attachTo(mfeRootElement); + * // call cleanup() when the MFE unmounts + * ``` + * + * Alternatively, use `` for a declarative approach: + * + * ```html + * * ``` */ static createInstance(): CatIconRegistry { @@ -117,6 +127,50 @@ export class CatIconRegistry { return instance; } + /** + * 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) — only when this instance is not the global singleton itself + * + * Returns a cleanup function that removes the listener. Call it when the + * element is removed from the DOM (e.g. MFE unmount, `disconnectedCallback`). + * + * ```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; + event.stopPropagation(); + event.preventDefault(); + + const { name, resolve } = event.detail; + + // 1. This (scoped) registry + if (this.hasIcon(name)) { + resolve(this.getIcon(name) as string); + return; + } + + // 2. Global registry fallback (framework defaults, host-app icons) + if (this !== catIconRegistry && catIconRegistry.hasIcon(name)) { + resolve(catIconRegistry.getIcon(name) as string); + } + }; + + element.addEventListener('cat-icon-request', handler); + return () => element.removeEventListener('cat-icon-request', handler); + } + hasIcon(name: string, setName?: string): boolean { return this.icons.has(this.buildName(name, setName)); } From fe79a597bccd01741add122521feb157ea3c9c0d Mon Sep 17 00:00:00 2001 From: anastasiia_glushkova Date: Thu, 2 Apr 2026 10:35:50 +0200 Subject: [PATCH 03/12] feat(core): remove CatIconProvider --- .../catalyst/src/lib/directives/proxies.ts | 25 --- core/src/components.d.ts | 147 ------------------ .../cat-icon-provider.spec.tsx | 114 -------------- .../cat-icon-provider/cat-icon-provider.tsx | 87 ----------- .../components/cat-icon-provider/readme.md | 87 ----------- core/src/components/cat-icon/cat-icon.tsx | 5 +- .../src/components/stencil-generated/index.ts | 1 - 7 files changed, 2 insertions(+), 464 deletions(-) delete mode 100644 core/src/components/cat-icon-provider/cat-icon-provider.spec.tsx delete mode 100644 core/src/components/cat-icon-provider/cat-icon-provider.tsx delete mode 100644 core/src/components/cat-icon-provider/readme.md diff --git a/angular/projects/catalyst/src/lib/directives/proxies.ts b/angular/projects/catalyst/src/lib/directives/proxies.ts index e9b8032e2..5ee1508aa 100644 --- a/angular/projects/catalyst/src/lib/directives/proxies.ts +++ b/angular/projects/catalyst/src/lib/directives/proxies.ts @@ -715,31 +715,6 @@ export class CatIcon { export declare interface CatIcon extends Components.CatIcon {} -@ProxyCmp({ - inputs: ['registry'] -}) -@Component({ - selector: 'cat-icon-provider', - changeDetection: ChangeDetectionStrategy.OnPush, - template: '', - // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['registry'], - standalone: false -}) -export class CatIconProvider { - protected el: HTMLCatIconProviderElement; - constructor( - c: ChangeDetectorRef, - r: ElementRef, - protected z: NgZone - ) { - c.detach(); - this.el = r.nativeElement; - } -} - -export declare interface CatIconProvider extends Components.CatIconProvider {} - @ProxyCmp({ inputs: [ 'accept', diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 4222ed82c..35757b31e 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -10,7 +10,6 @@ import { ErrorMap } from "./components/cat-form-hint/cat-form-hint"; import { Placement } from "@floating-ui/dom"; import { CatDatepickerMode } from "./components/cat-datepicker/cat-datepicker.mode"; import { BaseOptions } from "flatpickr/dist/types/options"; -import { CatIconRegistry } from "./components/cat-icon/cat-icon-registry"; import { InputType } from "./components/cat-input/input-type"; import { FormatDateMaskOptions, FormatTimeMaskOptions } from "./components/cat-input/cat-input"; import { CatSelectConnector, CatSelectValue, Item } from "./components/cat-select/cat-select"; @@ -21,7 +20,6 @@ export { ErrorMap } from "./components/cat-form-hint/cat-form-hint"; export { Placement } from "@floating-ui/dom"; export { CatDatepickerMode } from "./components/cat-datepicker/cat-datepicker.mode"; export { BaseOptions } from "flatpickr/dist/types/options"; -export { CatIconRegistry } from "./components/cat-icon/cat-icon-registry"; export { InputType } from "./components/cat-input/input-type"; export { FormatDateMaskOptions, FormatTimeMaskOptions } from "./components/cat-input/cat-input"; export { CatSelectConnector, CatSelectValue, Item } from "./components/cat-select/cat-select"; @@ -882,43 +880,6 @@ export namespace Components { */ "size": 'xs' | 's' | 'm' | 'l' | 'xl' | 'inline'; } - /** - * Provides a scoped `CatIconRegistry` instance to all descendant `cat-icon` - * components. - * Use this component to isolate icon sets in micro-frontend architectures where - * multiple MFEs register icons with the same names but different SVG content. - * ## Basic usage - * ```ts - * // In your MFE bootstrap code: - * const registry = CatIconRegistry.createInstance(); - * registry.addIcons(myIcons); - * ``` - * ```html - * - * - * - * - * ``` - * ## Resolution order for child `cat-icon` elements - * 1. The `registry` prop of the nearest `cat-icon-provider` ancestor - * 2. The global `catIconRegistry` singleton (framework default icons and any - * icons added via the legacy `catIconRegistry.addIcons()` API) - * 3. If neither has the icon, `cat-icon` logs an error and renders nothing - * ## Angular example - * ```ts - * @Component ({ template: `...` }) - * export class MfeRootComponent { - * readonly registry = CatIconRegistry.createInstance(); - * constructor() { this.registry.addIcons(myIcons); } - * } - * ``` - */ - interface CatIconProvider { - /** - * The isolated registry instance for this subtree. Create one with `CatIconRegistry.createInstance()`. If omitted, the global `catIconRegistry` is used (same as no provider). - */ - "registry"?: CatIconRegistry; - } /** * Inputs are used to allow users to provide text input when the expected input * is short. As well as plain text, Input supports various types of text, @@ -2519,43 +2480,6 @@ declare global { prototype: HTMLCatIconElement; new (): HTMLCatIconElement; }; - /** - * Provides a scoped `CatIconRegistry` instance to all descendant `cat-icon` - * components. - * Use this component to isolate icon sets in micro-frontend architectures where - * multiple MFEs register icons with the same names but different SVG content. - * ## Basic usage - * ```ts - * // In your MFE bootstrap code: - * const registry = CatIconRegistry.createInstance(); - * registry.addIcons(myIcons); - * ``` - * ```html - * - * - * - * - * ``` - * ## Resolution order for child `cat-icon` elements - * 1. The `registry` prop of the nearest `cat-icon-provider` ancestor - * 2. The global `catIconRegistry` singleton (framework default icons and any - * icons added via the legacy `catIconRegistry.addIcons()` API) - * 3. If neither has the icon, `cat-icon` logs an error and renders nothing - * ## Angular example - * ```ts - * @Component ({ template: `...` }) - * export class MfeRootComponent { - * readonly registry = CatIconRegistry.createInstance(); - * constructor() { this.registry.addIcons(myIcons); } - * } - * ``` - */ - interface HTMLCatIconProviderElement extends Components.CatIconProvider, HTMLStencilElement { - } - var HTMLCatIconProviderElement: { - prototype: HTMLCatIconProviderElement; - new (): HTMLCatIconProviderElement; - }; interface HTMLCatInputElementEventMap { "catChange": string; "catFocus": FocusEvent; @@ -2924,7 +2848,6 @@ declare global { "cat-dropdown": HTMLCatDropdownElement; "cat-form-group": HTMLCatFormGroupElement; "cat-icon": HTMLCatIconElement; - "cat-icon-provider": HTMLCatIconProviderElement; "cat-input": HTMLCatInputElement; "cat-menu": HTMLCatMenuElement; "cat-menu-item": HTMLCatMenuItemElement; @@ -3793,43 +3716,6 @@ declare namespace LocalJSX { */ "size"?: 'xs' | 's' | 'm' | 'l' | 'xl' | 'inline'; } - /** - * Provides a scoped `CatIconRegistry` instance to all descendant `cat-icon` - * components. - * Use this component to isolate icon sets in micro-frontend architectures where - * multiple MFEs register icons with the same names but different SVG content. - * ## Basic usage - * ```ts - * // In your MFE bootstrap code: - * const registry = CatIconRegistry.createInstance(); - * registry.addIcons(myIcons); - * ``` - * ```html - * - * - * - * - * ``` - * ## Resolution order for child `cat-icon` elements - * 1. The `registry` prop of the nearest `cat-icon-provider` ancestor - * 2. The global `catIconRegistry` singleton (framework default icons and any - * icons added via the legacy `catIconRegistry.addIcons()` API) - * 3. If neither has the icon, `cat-icon` logs an error and renders nothing - * ## Angular example - * ```ts - * @Component ({ template: `...` }) - * export class MfeRootComponent { - * readonly registry = CatIconRegistry.createInstance(); - * constructor() { this.registry.addIcons(myIcons); } - * } - * ``` - */ - interface CatIconProvider { - /** - * The isolated registry instance for this subtree. Create one with `CatIconRegistry.createInstance()`. If omitted, the global `catIconRegistry` is used (same as no provider). - */ - "registry"?: CatIconRegistry; - } /** * Inputs are used to allow users to provide text input when the expected input * is short. As well as plain text, Input supports various types of text, @@ -5164,7 +5050,6 @@ declare namespace LocalJSX { "cat-dropdown": CatDropdown; "cat-form-group": CatFormGroup; "cat-icon": CatIcon; - "cat-icon-provider": CatIconProvider; "cat-input": CatInput; "cat-menu": CatMenu; "cat-menu-item": CatMenuItem; @@ -5247,38 +5132,6 @@ declare module "@stencil/core" { * doesn't fit. */ "cat-icon": LocalJSX.CatIcon & JSXBase.HTMLAttributes; - /** - * Provides a scoped `CatIconRegistry` instance to all descendant `cat-icon` - * components. - * Use this component to isolate icon sets in micro-frontend architectures where - * multiple MFEs register icons with the same names but different SVG content. - * ## Basic usage - * ```ts - * // In your MFE bootstrap code: - * const registry = CatIconRegistry.createInstance(); - * registry.addIcons(myIcons); - * ``` - * ```html - * - * - * - * - * ``` - * ## Resolution order for child `cat-icon` elements - * 1. The `registry` prop of the nearest `cat-icon-provider` ancestor - * 2. The global `catIconRegistry` singleton (framework default icons and any - * icons added via the legacy `catIconRegistry.addIcons()` API) - * 3. If neither has the icon, `cat-icon` logs an error and renders nothing - * ## Angular example - * ```ts - * @Component ({ template: `...` }) - * export class MfeRootComponent { - * readonly registry = CatIconRegistry.createInstance(); - * constructor() { this.registry.addIcons(myIcons); } - * } - * ``` - */ - "cat-icon-provider": LocalJSX.CatIconProvider & JSXBase.HTMLAttributes; /** * Inputs are used to allow users to provide text input when the expected input * is short. As well as plain text, Input supports various types of text, diff --git a/core/src/components/cat-icon-provider/cat-icon-provider.spec.tsx b/core/src/components/cat-icon-provider/cat-icon-provider.spec.tsx deleted file mode 100644 index 8dd27e054..000000000 --- a/core/src/components/cat-icon-provider/cat-icon-provider.spec.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// import { newSpecPage } from '@stencil/core/testing'; -// import { CatIconProvider } from './cat-icon-provider'; -// import { CatIconRegistry } from '../cat-icon/cat-icon-registry'; -// -// describe('cat-icon-provider', () => { -// it('renders slot content', async () => { -// const page = await newSpecPage({ -// components: [CatIconProvider], -// html: 'child' -// }); -// expect(page.root).toBeTruthy(); -// }); -// -// describe('handleIconRequest', () => { -// function makeEvent(name: string): { event: CustomEvent; resolved?: string } { -// const ctx: { resolved?: string } = {}; -// const event = new CustomEvent('cat-icon-request', { -// bubbles: true, -// composed: true, -// cancelable: true, -// detail: { -// name, -// resolve: (svg: string) => { -// ctx.resolved = svg; -// } -// } -// }); -// return { event, ...ctx }; -// } -// -// it('cancels the event and resolves from the scoped registry when the icon exists', async () => { -// const registry = CatIconRegistry.createInstance(); -// registry.addIcons({ home: '' }); -// -// const page = await newSpecPage({ -// components: [CatIconProvider], -// html: '' -// }); -// const instance = page.rootInstance as CatIconProvider; -// (instance as any).registry = registry; -// -// const ctx: { resolved?: string } = {}; -// const event = new CustomEvent('cat-icon-request', { -// bubbles: true, -// cancelable: true, -// detail: { name: 'home', resolve: (svg: string) => { ctx.resolved = svg; } } -// }); -// -// instance.handleIconRequest(event as CustomEvent); -// -// expect(event.defaultPrevented).toBe(true); -// expect(ctx.resolved).toBe(''); -// }); -// -// it('falls back to global registry when scoped registry does not have the icon', async () => { -// const { catIconRegistry } = await import('../cat-icon/cat-icon-registry'); -// catIconRegistry.addIcons({ 'global-only': '' }); -// -// const scopedRegistry = CatIconRegistry.createInstance(); -// const page = await newSpecPage({ -// components: [CatIconProvider], -// html: '' -// }); -// const instance = page.rootInstance as CatIconProvider; -// (instance as any).registry = scopedRegistry; -// -// const ctx: { resolved?: string } = {}; -// const event = new CustomEvent('cat-icon-request', { -// bubbles: true, -// cancelable: true, -// detail: { name: 'global-only', resolve: (svg: string) => { ctx.resolved = svg; } } -// }); -// -// instance.handleIconRequest(event as CustomEvent); -// -// expect(event.defaultPrevented).toBe(true); -// expect(ctx.resolved).toBe(''); -// -// catIconRegistry.removeIcons(['global-only']); -// }); -// -// it('cancels the event even when the icon is not found in any registry', async () => { -// const page = await newSpecPage({ -// components: [CatIconProvider], -// html: '' -// }); -// const instance = page.rootInstance as CatIconProvider; -// -// const ctx: { resolved?: string } = {}; -// const event = new CustomEvent('cat-icon-request', { -// bubbles: true, -// cancelable: true, -// detail: { name: 'does-not-exist', resolve: (svg: string) => { ctx.resolved = svg; } } -// }); -// -// instance.handleIconRequest(event as CustomEvent); -// -// expect(event.defaultPrevented).toBe(true); -// expect(ctx.resolved).toBeUndefined(); -// }); -// -// it('dispatches cat-icon-request as a bubbling, cancelable event', async () => { -// // Verifies the event contract that cat-icon consumers depend on -// const event = new CustomEvent('cat-icon-request', { -// bubbles: true, -// composed: true, -// cancelable: true, -// detail: { name: 'home', resolve: jest.fn() } -// }); -// expect(event.bubbles).toBe(true); -// expect(event.cancelable).toBe(true); -// }); -// }); -// }); diff --git a/core/src/components/cat-icon-provider/cat-icon-provider.tsx b/core/src/components/cat-icon-provider/cat-icon-provider.tsx deleted file mode 100644 index b73b5c7fa..000000000 --- a/core/src/components/cat-icon-provider/cat-icon-provider.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Component, Element, h, Prop, Watch } from '@stencil/core'; -import { CatIconRegistry, catIconRegistry } from '../cat-icon/cat-icon-registry'; - -/** - * Provides a scoped `CatIconRegistry` instance to all descendant `cat-icon` - * components. - * - * Use this component to isolate icon sets in micro-frontend architectures where - * multiple MFEs register icons with the same names but different SVG content. - * - * ## Basic usage - * - * ```ts - * // In your MFE bootstrap code: - * const registry = CatIconRegistry.createInstance(); - * registry.addIcons(myIcons); - * ``` - * - * ```html - * - * - * - * - * ``` - * - * ## Resolution order for child `cat-icon` elements - * - * 1. The `registry` prop of the nearest `cat-icon-provider` ancestor - * 2. The global `catIconRegistry` singleton (framework default icons and any - * icons added via the legacy `catIconRegistry.addIcons()` API) - * 3. If neither has the icon, `cat-icon` renders nothing - * - * ## Imperative alternative (no wrapper element) - * - * If adding an extra wrapper element is undesirable (e.g. in a bootstrap - * component that already owns the MFE root), use `attachTo` directly: - * - * ```ts - * @Component({ ... }) - * export class MfeRootComponent implements OnInit, OnDestroy { - * readonly registry = CatIconRegistry.createInstance(); - * private cleanup?: () => void; - * - * constructor(private el: ElementRef) { - * this.registry.addIcons(myIcons); - * } - * - * ngOnInit() { this.cleanup = this.registry.attachTo(this.el.nativeElement); } - * ngOnDestroy() { this.cleanup?.(); } - * } - * ``` - */ -@Component({ - tag: 'cat-icon-provider', - shadow: false -}) -export class CatIconProvider { - @Element() el!: HTMLElement; - - /** - * The isolated registry instance for this subtree. - * Create one with `CatIconRegistry.createInstance()`. - * If omitted, the global `catIconRegistry` is used (same as no provider). - */ - @Prop() registry?: CatIconRegistry; - - private detach?: () => void; - - connectedCallback() { - this.reattach(); - } - - @Watch('registry') - reattach() { - this.detach?.(); - this.detach = (this.registry ?? catIconRegistry).attachTo(this.el); - } - - disconnectedCallback() { - this.detach?.(); - this.detach = undefined; - } - - render() { - return ; - } -} diff --git a/core/src/components/cat-icon-provider/readme.md b/core/src/components/cat-icon-provider/readme.md deleted file mode 100644 index 39e781af5..000000000 --- a/core/src/components/cat-icon-provider/readme.md +++ /dev/null @@ -1,87 +0,0 @@ -# cat-icon-provider - -Provides a scoped `CatIconRegistry` to all descendant `cat-icon` components. Use this in micro-frontend architectures where multiple MFEs register icons with the same names but different SVG content. - -## Usage - -```ts -// MFE bootstrap -import { CatIconRegistry } from '@haiilo/catalyst'; -import * as myIcons from './icons'; - -const registry = CatIconRegistry.createInstance(); -registry.addIcons(myIcons); -``` - -```html - - - - -``` - -### Angular - -```ts -@Component({ - template: `` -}) -export class AppComponent { - readonly registry = CatIconRegistry.createInstance(); - constructor() { this.registry.addIcons(myIcons); } -} -``` - -## Icon resolution order - -1. **Scoped registry** (`registry` prop) — MFE-specific icons -2. **Global `catIconRegistry`** — framework default icons and host-app icons -3. If neither resolves the icon, `cat-icon` renders nothing and logs an error - - - - -## Overview - -Provides a scoped `CatIconRegistry` instance to all descendant `cat-icon` -components. - -Use this component to isolate icon sets in micro-frontend architectures where -multiple MFEs register icons with the same names but different SVG content. - -## Basic usage - -```ts -// In your MFE bootstrap code: -const registry = CatIconRegistry.createInstance(); -registry.addIcons(myIcons); -``` - -```html - - - - -``` - -## Resolution order for child `cat-icon` elements - -1. The `registry` prop of the nearest `cat-icon-provider` ancestor -2. The global `catIconRegistry` singleton (framework default icons and any - icons added via the legacy `catIconRegistry.addIcons()` API) -3. If neither has the icon, `cat-icon` logs an error and renders nothing - -## Angular example - -```ts - -## Properties - -| Property | Attribute | Description | Type | Default | -| ---------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ----------- | -| `registry` | -- | The isolated registry instance for this subtree. Create one with `CatIconRegistry.createInstance()`. If omitted, the global `catIconRegistry` is used (same as no provider). | `CatIconRegistry \| undefined` | `undefined` | - - ----------------------------------------------- - -Made with love in Hamburg, Germany diff --git a/core/src/components/cat-icon/cat-icon.tsx b/core/src/components/cat-icon/cat-icon.tsx index 2143bd2e4..3db85a2e0 100644 --- a/core/src/components/cat-icon/cat-icon.tsx +++ b/core/src/components/cat-icon/cat-icon.tsx @@ -51,7 +51,6 @@ export class CatIcon { return; } - const name = this.icon; let resolved = false; const event = new CustomEvent('cat-icon-request', { @@ -59,7 +58,7 @@ export class CatIcon { composed: true, cancelable: true, detail: { - name, + name: this.icon, resolve: (svg: string) => { this.resolvedSvg = svg; resolved = true; @@ -72,7 +71,7 @@ export class CatIcon { if (notCancelled) { // No cat-icon-provider in the ancestry — use the global registry directly // (preserves the pre-existing behavior for apps that don't use providers). - this.resolvedSvg = icons.getIcon(name); + this.resolvedSvg = icons.getIcon(this.icon); } else if (!resolved) { // A provider took ownership but could not find the icon in any registry. this.resolvedSvg = undefined; diff --git a/react/src/components/stencil-generated/index.ts b/react/src/components/stencil-generated/index.ts index c2229b01c..b1632e9a8 100644 --- a/react/src/components/stencil-generated/index.ts +++ b/react/src/components/stencil-generated/index.ts @@ -21,7 +21,6 @@ export const CatDatepickerInline = /*@__PURE__*/createReactComponent('cat-dropdown'); export const CatFormGroup = /*@__PURE__*/createReactComponent('cat-form-group'); export const CatIcon = /*@__PURE__*/createReactComponent('cat-icon'); -export const CatIconProvider = /*@__PURE__*/createReactComponent('cat-icon-provider'); export const CatInput = /*@__PURE__*/createReactComponent('cat-input'); export const CatMenu = /*@__PURE__*/createReactComponent('cat-menu'); export const CatMenuItem = /*@__PURE__*/createReactComponent('cat-menu-item'); From aceebfa4319c95bdf4812ff78a544d0180789059 Mon Sep 17 00:00:00 2001 From: anastasiia_glushkova Date: Tue, 7 Apr 2026 13:17:41 +0200 Subject: [PATCH 04/12] feat(core): replace stopPropagation with stopImmediatePropagation --- core/src/components/cat-icon/cat-icon-registry.ts | 9 +-------- core/src/components/cat-icon/cat-icon-request.ts | 4 ++-- core/src/components/cat-icon/cat-icon.tsx | 2 +- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/core/src/components/cat-icon/cat-icon-registry.ts b/core/src/components/cat-icon/cat-icon-registry.ts index c73660a90..27006d0c9 100644 --- a/core/src/components/cat-icon/cat-icon-registry.ts +++ b/core/src/components/cat-icon/cat-icon-registry.ts @@ -114,12 +114,6 @@ export class CatIconRegistry { * const cleanup = registry.attachTo(mfeRootElement); * // call cleanup() when the MFE unmounts * ``` - * - * Alternatively, use `` for a declarative approach: - * - * ```html - * - * ``` */ static createInstance(): CatIconRegistry { const instance = new CatIconRegistry(false); @@ -150,8 +144,7 @@ export class CatIconRegistry { attachTo(element: Element): () => void { const handler = (e: Event) => { const event = e as CustomEvent; - event.stopPropagation(); - event.preventDefault(); + event.stopImmediatePropagation(); const { name, resolve } = event.detail; diff --git a/core/src/components/cat-icon/cat-icon-request.ts b/core/src/components/cat-icon/cat-icon-request.ts index 7a12b35e7..2d355666b 100644 --- a/core/src/components/cat-icon/cat-icon-request.ts +++ b/core/src/components/cat-icon/cat-icon-request.ts @@ -2,7 +2,7 @@ * Detail payload for the `cat-icon-request` custom event. * * `cat-icon` dispatches this event (bubbling, composed, cancelable) to let an - * ancestor `cat-icon-provider` resolve the SVG for the requested icon name. + * 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`. @@ -10,6 +10,6 @@ export interface CatIconRequestDetail { /** The icon name as passed to the `icon` prop of `cat-icon`. */ readonly name: string; - /** Called by the nearest `cat-icon-provider` with the resolved SVG 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.tsx b/core/src/components/cat-icon/cat-icon.tsx index 3db85a2e0..d49e1e85a 100644 --- a/core/src/components/cat-icon/cat-icon.tsx +++ b/core/src/components/cat-icon/cat-icon.tsx @@ -69,7 +69,7 @@ export class CatIcon { const notCancelled = this.el.dispatchEvent(event); if (notCancelled) { - // No cat-icon-provider in the ancestry — use the global registry directly + // No cat-icon-regisrty 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); } else if (!resolved) { From 129b8b7c61b8f23e5286b37ed758c333d7ad244a Mon Sep 17 00:00:00 2001 From: anastasiia_glushkova Date: Tue, 28 Apr 2026 13:22:11 +0200 Subject: [PATCH 05/12] feat(core): remove extra complication --- .../components/cat-icon/cat-icon-registry.ts | 21 +++++++------------ .../components/cat-icon/cat-icon-request.ts | 4 ++-- core/src/components/cat-icon/cat-icon.tsx | 16 +------------- 3 files changed, 10 insertions(+), 31 deletions(-) diff --git a/core/src/components/cat-icon/cat-icon-registry.ts b/core/src/components/cat-icon/cat-icon-registry.ts index 27006d0c9..1f3ecb43e 100644 --- a/core/src/components/cat-icon/cat-icon-registry.ts +++ b/core/src/components/cat-icon/cat-icon-registry.ts @@ -29,15 +29,12 @@ 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(registerDefaults = true) { + private constructor() { // hide constructor - if (!registerDefaults) { - return; - } - // register default icons that are used in the framework by other components this.addIcons( { @@ -101,8 +98,6 @@ export class CatIconRegistry { * * Unlike the global singleton, this instance: * - Does not sync icons with other registry instances via window events - * - Does not pre-register framework default icons (they are resolved via - * the global singleton fallback in `attachTo`) * * Use `attachTo` to scope icons to a DOM subtree without adding a wrapper * element: @@ -116,9 +111,7 @@ export class CatIconRegistry { * ``` */ static createInstance(): CatIconRegistry { - const instance = new CatIconRegistry(false); - instance.syncIcons = false; - return instance; + return new CatIconRegistry(); } /** @@ -128,10 +121,10 @@ export class CatIconRegistry { * Resolution order: * 1. This registry instance (scoped icons) * 2. The global `catIconRegistry` singleton (framework defaults / host app - * icons) — only when this instance is not the global singleton itself + * icons) * * Returns a cleanup function that removes the listener. Call it when the - * element is removed from the DOM (e.g. MFE unmount, `disconnectedCallback`). + * element is removed from the DOM (e.g. MFE unmount/destroy/disconnect). * * ```ts * const registry = CatIconRegistry.createInstance(); @@ -155,7 +148,7 @@ export class CatIconRegistry { } // 2. Global registry fallback (framework defaults, host-app icons) - if (this !== catIconRegistry && catIconRegistry.hasIcon(name)) { + if (catIconRegistry.hasIcon(name)) { resolve(catIconRegistry.getIcon(name) as string); } }; @@ -164,7 +157,7 @@ export class CatIconRegistry { return () => element.removeEventListener('cat-icon-request', handler); } - hasIcon(name: string, setName?: string): boolean { + private hasIcon(name: string, setName?: string): boolean { return this.icons.has(this.buildName(name, setName)); } diff --git a/core/src/components/cat-icon/cat-icon-request.ts b/core/src/components/cat-icon/cat-icon-request.ts index 2d355666b..0b1237e24 100644 --- a/core/src/components/cat-icon/cat-icon-request.ts +++ b/core/src/components/cat-icon/cat-icon-request.ts @@ -1,9 +1,9 @@ /** * Detail payload for the `cat-icon-request` custom event. * - * `cat-icon` dispatches this event (bubbling, composed, cancelable) to let an + * `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()` + * The provider calls `resolve(svg)` synchronously and calls `stopImmediatePropagation()` * to signal that the request was handled. If no provider cancels the event, * `cat-icon` falls back to the global `catIconRegistry`. */ diff --git a/core/src/components/cat-icon/cat-icon.tsx b/core/src/components/cat-icon/cat-icon.tsx index d49e1e85a..10651466d 100644 --- a/core/src/components/cat-icon/cat-icon.tsx +++ b/core/src/components/cat-icon/cat-icon.tsx @@ -1,6 +1,5 @@ import { Component, Element, h, Prop, State, Watch } from '@stencil/core'; import { CatIconRequestDetail } from './cat-icon-request'; -import { catIconRegistry as icons } from './cat-icon-registry'; /** * Icons are used to provide additional meaning or in places where text label @@ -51,31 +50,18 @@ export class CatIcon { return; } - let resolved = false; - const event = new CustomEvent('cat-icon-request', { bubbles: true, composed: true, - cancelable: true, detail: { name: this.icon, resolve: (svg: string) => { this.resolvedSvg = svg; - resolved = true; } } }); - const notCancelled = this.el.dispatchEvent(event); - - if (notCancelled) { - // No cat-icon-regisrty 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); - } else if (!resolved) { - // A provider took ownership but could not find the icon in any registry. - this.resolvedSvg = undefined; - } + this.el.dispatchEvent(event); } render() { From d2a53c4d0cf2994b690f4f78d9cdcfbcf97e528c Mon Sep 17 00:00:00 2001 From: anastasiia_glushkova Date: Fri, 8 May 2026 00:39:55 +0200 Subject: [PATCH 06/12] feat(core): backword compatibility --- .../components/cat-icon/cat-icon-registry.ts | 13 ++++--- core/src/components/cat-icon/cat-icon.tsx | 9 ++++- pnpm-lock.yaml | 39 +++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/core/src/components/cat-icon/cat-icon-registry.ts b/core/src/components/cat-icon/cat-icon-registry.ts index 1f3ecb43e..76a8b2711 100644 --- a/core/src/components/cat-icon/cat-icon-registry.ts +++ b/core/src/components/cat-icon/cat-icon-registry.ts @@ -27,13 +27,15 @@ export class CatIconRegistry { private readonly id = (Math.random() + 1).toString(36).substring(2); private readonly icons: Map = new Map(); + private isScoped = false; // 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 + this.isScoped = isScoped; // register default icons that are used in the framework by other components this.addIcons( @@ -74,13 +76,13 @@ export class CatIconRegistry { // this registry. window.addEventListener('cat-icons-added', event => { const { detail } = (event as CustomEvent) || {}; - if (this.syncIcons && detail && detail.id !== this.id) { + if (this.syncIcons && detail && detail.id !== this.id && !this.isScoped) { 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) { + if (this.syncIcons && detail && detail.id !== this.id && !this.isScoped) { this.removeIcons(detail.names, detail.setName, true); } }); @@ -111,7 +113,7 @@ export class CatIconRegistry { * ``` */ static createInstance(): CatIconRegistry { - return new CatIconRegistry(); + return new CatIconRegistry(true); } /** @@ -129,7 +131,7 @@ export class CatIconRegistry { * ```ts * const registry = CatIconRegistry.createInstance(); * registry.addIcons(myIcons); - * const cleanup = registry.attachTo(document.querySelector('mfe-root')!); + * const cleanup = registry.attachTo(document.querySelector('mfe-root')); * // later… * cleanup(); * ``` @@ -137,6 +139,7 @@ export class CatIconRegistry { attachTo(element: Element): () => void { const handler = (e: Event) => { const event = e as CustomEvent; + event.preventDefault(); event.stopImmediatePropagation(); const { name, resolve } = event.detail; diff --git a/core/src/components/cat-icon/cat-icon.tsx b/core/src/components/cat-icon/cat-icon.tsx index 10651466d..b5fca7cc3 100644 --- a/core/src/components/cat-icon/cat-icon.tsx +++ b/core/src/components/cat-icon/cat-icon.tsx @@ -1,5 +1,6 @@ import { Component, Element, h, Prop, State, Watch } from '@stencil/core'; import { CatIconRequestDetail } from './cat-icon-request'; +import { catIconRegistry as icons } from './cat-icon-registry'; /** * Icons are used to provide additional meaning or in places where text label @@ -52,6 +53,7 @@ export class CatIcon { const event = new CustomEvent('cat-icon-request', { bubbles: true, + cancelable: true, composed: true, detail: { name: this.icon, @@ -60,8 +62,13 @@ export class CatIcon { } } }); + const notCancelled = this.el.dispatchEvent(event); - this.el.dispatchEvent(event); + if (notCancelled) { + // No cat-icon-regisrty 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() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9797ca2af..686c3ef29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,45 @@ importers: specifier: ^4.0.16 version: 4.1.0(@types/node@12.20.55)(@vitest/browser-playwright@4.1.0)(@vitest/ui@4.0.16)(jsdom@27.4.0)(vite@7.3.0(@types/node@12.20.55)(jiti@2.6.1)(less@4.6.4)(sass-embedded@1.98.0)(sass@1.97.1)) + angular/dist/catalyst: + dependencies: + '@angular/cdk': + specifier: '>=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': From b0056a73d82b4e7a5e46d9872755a521885b2267 Mon Sep 17 00:00:00 2001 From: anastasiia_glushkova Date: Fri, 8 May 2026 00:44:57 +0200 Subject: [PATCH 07/12] feat(core): prettier --- core/src/components/cat-icon/cat-icon-registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/components/cat-icon/cat-icon-registry.ts b/core/src/components/cat-icon/cat-icon-registry.ts index 76a8b2711..62cebd97b 100644 --- a/core/src/components/cat-icon/cat-icon-registry.ts +++ b/core/src/components/cat-icon/cat-icon-registry.ts @@ -35,7 +35,7 @@ export class CatIconRegistry { private constructor(isScoped = false) { // hide constructor - this.isScoped = isScoped; + this.isScoped = isScoped; // register default icons that are used in the framework by other components this.addIcons( From 4fda446e68ace99c2676a4b922347f4e27ae7e8c Mon Sep 17 00:00:00 2001 From: Anastasiia Glushkova Date: Sun, 10 May 2026 21:46:05 +0200 Subject: [PATCH 08/12] fix(core): fix typo Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- core/src/components/cat-icon/cat-icon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/components/cat-icon/cat-icon.tsx b/core/src/components/cat-icon/cat-icon.tsx index b5fca7cc3..c302ce366 100644 --- a/core/src/components/cat-icon/cat-icon.tsx +++ b/core/src/components/cat-icon/cat-icon.tsx @@ -65,7 +65,7 @@ export class CatIcon { const notCancelled = this.el.dispatchEvent(event); if (notCancelled) { - // No cat-icon-regisrty instance in the ancestry — use the global registry directly + // 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); } From eb2f82afcb93511cba5a91c2d832fddb9133a2b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 19:46:43 +0000 Subject: [PATCH 09/12] Fix removeEventListener to use stored handler reference in cat-icon test Agent-Logs-Url: https://github.com/haiilo/catalyst/sessions/d80ba7e9-14c0-4359-a1c5-72b2f8ea0faf Co-authored-by: glushkova91 <13402897+glushkova91@users.noreply.github.com> --- core/src/components/cat-icon/cat-icon.spec.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/src/components/cat-icon/cat-icon.spec.tsx b/core/src/components/cat-icon/cat-icon.spec.tsx index acf6afa19..43b171c9f 100644 --- a/core/src/components/cat-icon/cat-icon.spec.tsx +++ b/core/src/components/cat-icon/cat-icon.spec.tsx @@ -40,7 +40,8 @@ describe('cat-icon', () => { }); const catIcon = page.root!; const events: CustomEvent[] = []; - document.body.addEventListener('cat-icon-request', e => events.push(e as CustomEvent)); + const handler = (e: Event) => events.push(e as CustomEvent); + document.body.addEventListener('cat-icon-request', handler); catIcon.setAttribute('icon', 'home'); await page.waitForChanges(); @@ -50,6 +51,6 @@ describe('cat-icon', () => { expect(iconEvents[0].cancelable).toBe(true); expect(iconEvents[0].bubbles).toBe(true); - document.body.removeEventListener('cat-icon-request', e => events.push(e as CustomEvent)); + document.body.removeEventListener('cat-icon-request', handler); }); }); From 8e967cb2f248b526a7b27ebce6c110af0476705e Mon Sep 17 00:00:00 2001 From: Anastasiia Glushkova Date: Sun, 10 May 2026 21:47:11 +0200 Subject: [PATCH 10/12] fix(core): fix docs Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- core/src/components/cat-icon/cat-icon-request.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/components/cat-icon/cat-icon-request.ts b/core/src/components/cat-icon/cat-icon-request.ts index 0b1237e24..cadb87e74 100644 --- a/core/src/components/cat-icon/cat-icon-request.ts +++ b/core/src/components/cat-icon/cat-icon-request.ts @@ -3,8 +3,8 @@ * * `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 `stopImmediatePropagation()` - * to signal that the request was handled. If no provider cancels the event, + * 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 { From 9df00470cfc543f30c16b35d58e02c6448c88c1d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 19:52:39 +0000 Subject: [PATCH 11/12] Skip window sync listeners for scoped registry instances to prevent listener leaks Agent-Logs-Url: https://github.com/haiilo/catalyst/sessions/67b1e587-7be6-4ea6-87cc-af76ee4fa30d Co-authored-by: glushkova91 <13402897+glushkova91@users.noreply.github.com> --- .../components/cat-icon/cat-icon-registry.ts | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/core/src/components/cat-icon/cat-icon-registry.ts b/core/src/components/cat-icon/cat-icon-registry.ts index 62cebd97b..5f4c9d4ca 100644 --- a/core/src/components/cat-icon/cat-icon-registry.ts +++ b/core/src/components/cat-icon/cat-icon-registry.ts @@ -27,7 +27,6 @@ export class CatIconRegistry { private readonly id = (Math.random() + 1).toString(36).substring(2); private readonly icons: Map = new Map(); - private isScoped = false; // ignore syncing in backwards compatible manner // @deprecated: create isolated registry instance via createInstance to avoid conflicts between different icons versions @@ -35,7 +34,6 @@ export class CatIconRegistry { private constructor(isScoped = false) { // hide constructor - this.isScoped = isScoped; // register default icons that are used in the framework by other components this.addIcons( @@ -73,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.isScoped) { - 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.isScoped) { - 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 { From 1e8bc52974370ea49aaaa49b823465802d946dba Mon Sep 17 00:00:00 2001 From: Anastasiia Glushkova Date: Sun, 10 May 2026 21:54:08 +0200 Subject: [PATCH 12/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/cat-icon/cat-icon-registry.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/core/src/components/cat-icon/cat-icon-registry.ts b/core/src/components/cat-icon/cat-icon-registry.ts index 5f4c9d4ca..2b1369eb6 100644 --- a/core/src/components/cat-icon/cat-icon-registry.ts +++ b/core/src/components/cat-icon/cat-icon-registry.ts @@ -140,21 +140,26 @@ export class CatIconRegistry { attachTo(element: Element): () => void { const handler = (e: Event) => { const event = e as CustomEvent; - event.preventDefault(); - event.stopImmediatePropagation(); - const { name, resolve } = event.detail; + let icon: string | undefined; // 1. This (scoped) registry if (this.hasIcon(name)) { - resolve(this.getIcon(name) as string); - return; + icon = this.getIcon(name); } // 2. Global registry fallback (framework defaults, host-app icons) - if (catIconRegistry.hasIcon(name)) { - resolve(catIconRegistry.getIcon(name) as string); + if (!icon && catIconRegistry.hasIcon(name)) { + icon = catIconRegistry.getIcon(name); + } + + if (!icon) { + return; } + + event.preventDefault(); + event.stopImmediatePropagation(); + resolve(icon); }; element.addEventListener('cat-icon-request', handler);