Skip to content

Commit 3cbeac5

Browse files
authored
feat(ui5-icon): add fontIcon slot for font-based icon libraries (#13569)
Introduces a named slot that renders a <span> wrapper instead of <svg>, allowing applications to use font-based icon libraries (Font Awesome, Material Icons, Bootstrap Icons, Phosphor, etc.) while keeping ui5-icon as the host for sizing, design tokens, color, and accessibility. Usage: ``` <ui5-icon> <i slot="fontIcon" class="fa-solid fa-house"></i> </ui5-icon> ``` Fixes: #8186
1 parent 6b86a10 commit 3cbeac5

5 files changed

Lines changed: 569 additions & 3 deletions

File tree

packages/main/cypress/specs/Icon.cy.tsx

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ describe("Icon general interaction", () => {
297297
cy.get("[ui5-icon][mode='Interactive']").then($icon => {
298298
const icon = $icon[0] as any;
299299
const accessibilityInfo = icon.accessibilityInfo;
300-
300+
301301
// For Interactive mode, accessibilityInfo should have role, type and description
302302
expect(accessibilityInfo).to.not.be.undefined;
303303
expect(accessibilityInfo.role).to.equal("button");
@@ -317,7 +317,7 @@ describe("Icon general interaction", () => {
317317
cy.get("[ui5-icon][mode='Decorative']").then($icon => {
318318
const icon = $icon[0] as any;
319319
const accessibilityInfo = icon.accessibilityInfo;
320-
320+
321321
// For Decorative mode, accessibilityInfo should return an empty object
322322
expect(accessibilityInfo).to.deep.equal({});
323323
});
@@ -334,7 +334,7 @@ describe("Icon general interaction", () => {
334334
cy.get("[ui5-icon][mode='Image']").then($icon => {
335335
const icon = $icon[0] as any;
336336
const accessibilityInfo = icon.accessibilityInfo;
337-
337+
338338
// For Image mode, accessibilityInfo should have role, type and description
339339
expect(accessibilityInfo).to.not.be.undefined;
340340
expect(accessibilityInfo.role).to.equal("img");
@@ -343,3 +343,143 @@ describe("Icon general interaction", () => {
343343
});
344344
});
345345
});
346+
347+
describe("Icon fontIcon slot", () => {
348+
it("renders a span root instead of svg when fontIcon slot is used", () => {
349+
cy.mount(
350+
<Icon>
351+
<span slot="fontIcon"></span>
352+
</Icon>
353+
);
354+
355+
cy.get("[ui5-icon]").shadow().find("span.ui5-icon-root").should("exist");
356+
cy.get("[ui5-icon]").shadow().find("svg").should("not.exist");
357+
});
358+
359+
it("Decorative mode: span root has role=presentation and aria-hidden=true", () => {
360+
cy.mount(
361+
<Icon>
362+
<span slot="fontIcon"></span>
363+
</Icon>
364+
);
365+
366+
cy.get("[ui5-icon]").shadow().find("span.ui5-icon-root")
367+
.should("have.attr", "role", "presentation")
368+
.should("have.attr", "aria-hidden", "true");
369+
});
370+
371+
it("Image mode: span root has role=img and aria-label", () => {
372+
cy.mount(
373+
<Icon mode="Image" accessibleName="Star">
374+
<span slot="fontIcon"></span>
375+
</Icon>
376+
);
377+
378+
cy.get("[ui5-icon]").shadow().find("span.ui5-icon-root")
379+
.should("have.attr", "role", "img")
380+
.should("have.attr", "aria-label", "Star")
381+
.should("not.have.attr", "aria-hidden");
382+
});
383+
384+
it("Interactive mode: span root has role=button and tabindex=0", () => {
385+
cy.mount(
386+
<Icon mode="Interactive" accessibleName="Add">
387+
<span slot="fontIcon"></span>
388+
</Icon>
389+
);
390+
391+
cy.get("[ui5-icon]").shadow().find("span.ui5-icon-root")
392+
.should("have.attr", "role", "button")
393+
.should("have.attr", "tabindex", "0")
394+
.should("have.attr", "aria-label", "Add");
395+
});
396+
397+
it("Interactive mode: fires ui5-click on mouse click", () => {
398+
cy.mount(
399+
<Icon mode="Interactive" accessibleName="Add">
400+
<span slot="fontIcon"></span>
401+
</Icon>
402+
);
403+
404+
cy.get("[ui5-icon]").then($icon => {
405+
$icon[0].addEventListener("ui5-click", cy.stub().as("ui5Click"));
406+
});
407+
408+
cy.get("[ui5-icon]").realClick();
409+
cy.get("@ui5Click").should("have.been.calledOnce");
410+
});
411+
412+
it("Interactive mode: fires ui5-click on Enter key", () => {
413+
cy.mount(
414+
<Icon mode="Interactive" accessibleName="Add">
415+
<span slot="fontIcon"></span>
416+
</Icon>
417+
);
418+
419+
cy.get("[ui5-icon]").then($icon => {
420+
$icon[0].addEventListener("ui5-click", cy.stub().as("ui5Click"));
421+
});
422+
423+
cy.get("[ui5-icon]").shadow().find("span.ui5-icon-root").focus();
424+
cy.realPress("Enter");
425+
cy.get("@ui5Click").should("have.been.calledOnce");
426+
});
427+
428+
it("Interactive mode: fires ui5-click on Space key", () => {
429+
cy.mount(
430+
<Icon mode="Interactive" accessibleName="Add">
431+
<span slot="fontIcon"></span>
432+
</Icon>
433+
);
434+
435+
cy.get("[ui5-icon]").then($icon => {
436+
$icon[0].addEventListener("ui5-click", cy.stub().as("ui5Click"));
437+
});
438+
439+
cy.get("[ui5-icon]").shadow().find("span.ui5-icon-root").focus();
440+
cy.realPress("Space");
441+
cy.get("@ui5Click").should("have.been.calledOnce");
442+
});
443+
444+
it("Decorative mode: does not fire ui5-click on click", () => {
445+
cy.mount(
446+
<Icon>
447+
<span slot="fontIcon"></span>
448+
</Icon>
449+
);
450+
451+
cy.get("[ui5-icon]").then($icon => {
452+
$icon[0].addEventListener("ui5-click", cy.stub().as("ui5Click"));
453+
});
454+
455+
cy.get("[ui5-icon]").realClick();
456+
cy.get("@ui5Click").should("not.have.been.called");
457+
});
458+
459+
it("no accessible-name: aria-label is not set", () => {
460+
cy.mount(
461+
<Icon mode="Image">
462+
<span slot="fontIcon"></span>
463+
</Icon>
464+
);
465+
466+
cy.get("[ui5-icon]").shadow().find("span.ui5-icon-root")
467+
.should("not.have.attr", "aria-label");
468+
});
469+
470+
it("accessible-name takes effect when set", () => {
471+
cy.mount(
472+
<Icon mode="Image" accessibleName="Initial">
473+
<span slot="fontIcon"></span>
474+
</Icon>
475+
);
476+
477+
cy.get("[ui5-icon]").shadow().find("span.ui5-icon-root")
478+
.should("have.attr", "aria-label", "Initial");
479+
480+
cy.get("[ui5-icon]").invoke("prop", "accessibleName", "Updated");
481+
482+
cy.get("[ui5-icon]").shadow().find("span.ui5-icon-root")
483+
.should("have.attr", "aria-label", "Updated");
484+
});
485+
});

packages/main/src/Icon.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import jsxRender from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
33
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
44
import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js";
55
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
6+
import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js";
67
import type { AriaRole } from "@ui5/webcomponents-base/dist/types.js";
8+
import type { Slot } from "@ui5/webcomponents-base/dist/UI5Element.js";
79
import type { IconData, UnsafeIconData } from "@ui5/webcomponents-base/dist/asset-registries/Icons.js";
810
import { getIconData, getIconDataSync } from "@ui5/webcomponents-base/dist/asset-registries/Icons.js";
911
import { getI18nBundle } from "@ui5/webcomponents-base/dist/i18nBundle.js";
@@ -194,6 +196,25 @@ class Icon extends UI5Element implements IIcon {
194196
@property()
195197
mode: `${IconMode}` = "Decorative";
196198

199+
/**
200+
* Defines the font icon to be used as an icon.
201+
* Intended for font-based icon libraries where
202+
* the application loads the font and provides a slotted element with the unicode character.
203+
* When this slot is used, the component renders a `<span>` instead of an `<svg>`.
204+
* Accessibility is fully delegated to the application — set `accessible-name` and `mode` explicitly.
205+
*
206+
* **Example:**
207+
* ```html
208+
* <ui5-icon mode="Image" accessible-name="Home">
209+
* <i class="fa fa-home" slot="fontIcon"></i>
210+
* </ui5-icon>
211+
* ```
212+
* @public
213+
* @since 2.23.0
214+
*/
215+
@slot({ type: HTMLElement })
216+
fontIcon!: Slot<HTMLElement>;
217+
197218
/**
198219
* @private
199220
*/
@@ -288,6 +309,16 @@ class Icon extends UI5Element implements IIcon {
288309
}
289310

290311
async onBeforeRendering() {
312+
if (this.fontIcon.length) {
313+
// Font-based icon via slot — skip registry, accessibility is app's responsibility
314+
if (!this.accessibleName) {
315+
this.effectiveAccessibleName = undefined;
316+
} else {
317+
this.effectiveAccessibleName = this.accessibleName;
318+
}
319+
return;
320+
}
321+
291322
const name = this.name;
292323
if (!name) {
293324
return;
@@ -344,6 +375,10 @@ class Icon extends UI5Element implements IIcon {
344375
}
345376
}
346377

378+
get hasFontIcon() {
379+
return this.fontIcon.length > 0;
380+
}
381+
347382
get hasIconTooltip() {
348383
return this.showTooltip && this.effectiveAccessibleName;
349384
}

packages/main/src/IconTemplate.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
11
import type Icon from "./Icon.js";
22

33
export default function IconTemplate(this: Icon) {
4+
if (this.hasFontIcon) {
5+
return (
6+
<span
7+
class="ui5-icon-root"
8+
part="root"
9+
tabindex={this._tabIndex}
10+
role={this.effectiveAccessibleRole}
11+
aria-label={this.effectiveAccessibleName}
12+
aria-hidden={this.effectiveAriaHidden}
13+
onKeyDown={this._onkeydown}
14+
onKeyUp={this._onkeyup}
15+
onClick={this._onclick}
16+
>
17+
<slot name="fontIcon"></slot>
18+
</span>
19+
);
20+
}
21+
422
return (
523
<svg
624
class="ui5-icon-root"

packages/main/src/themes/Icon.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
color: var(--sapContent_IconColor);
2121
fill: currentColor;
2222
outline: none;
23+
container-type: size;
2324
}
2425

2526
:host([design="Contrast"]) {
@@ -62,6 +63,11 @@
6263
width: 100%;
6364
outline: none;
6465
vertical-align: top;
66+
box-sizing: border-box;
67+
align-items: center;
68+
justify-content: center;
69+
font-size: min(100cqw, 100cqh);
70+
line-height: 1;
6571
}
6672

6773

0 commit comments

Comments
 (0)