Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions core/src/components/cat-icon/__mocks__/cat-icon-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
107 changes: 93 additions & 14 deletions core/src/components/cat-icon/cat-icon-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,9 +29,10 @@ export class CatIconRegistry {
private readonly icons: Map<string, string> = 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
Expand Down Expand Up @@ -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 {
Expand All @@ -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<CatIconRequestDetail>;
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) {
Expand Down
15 changes: 15 additions & 0 deletions core/src/components/cat-icon/cat-icon-request.ts
Original file line number Diff line number Diff line change
@@ -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;
}
35 changes: 33 additions & 2 deletions core/src/components/cat-icon/cat-icon.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<cat-icon />);
expect(root.shadowRoot).toEqualLightHtml(`
<span aria-hidden="true" part="icon" class="icon icon-m"></span>
`);
});

it('renders with an icon name (falls back to global registry when no provider)', async () => {
const { root } = await render(<cat-icon icon="icon" />);
await expect(root.shadowRoot).toEqualHtml(`
// The mock registry returns undefined, so innerHTML is empty
expect(root.shadowRoot).toEqualLightHtml(`
<span aria-hidden="true" part="icon" class="icon icon-m"></span>
`);
});

it('renders with iconSrc, bypassing registry entirely', async () => {
const { root } = await render(<cat-icon icon-src="<svg/>" />);
// JSDOM normalises self-closing SVG tags to <svg></svg>
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(<cat-icon />);
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);
});
});
41 changes: 39 additions & 2 deletions core/src/components/cat-icon/cat-icon.tsx
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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.
*/
Expand All @@ -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<CatIconRequestDetail>('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 (
<span
innerHTML={this.iconSrc || (this.icon ? icons.getIcon(this.icon) : '')}
innerHTML={this.iconSrc || this.resolvedSvg || ''}
aria-label={this.a11yLabel}
aria-hidden={this.a11yLabel ? null : 'true'}
part="icon"
Expand Down
1 change: 1 addition & 0 deletions core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { Components, JSX } from './components';
export { CatI18nRegistry, CatI18nTranslationFn, catI18nRegistry } from './components/cat-i18n/cat-i18n-registry';
export { CatIconRegistry, catIconRegistry } from './components/cat-icon/cat-icon-registry';
export { CatIconRequestDetail } from './components/cat-icon/cat-icon-request';
export {
CatNotificationService,
ToastOptions,
Expand Down
39 changes: 39 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading