diff --git a/projects/core/src/icon/icon.test.ts b/projects/core/src/icon/icon.test.ts
index 0a345712a..8c531b0ff 100644
--- a/projects/core/src/icon/icon.test.ts
+++ b/projects/core/src/icon/icon.test.ts
@@ -161,6 +161,47 @@ describe(Icon.metadata.tag, () => {
expect((customElements.get(Icon.metadata.tag) as typeof Icon)._icons['test-svg']).toBeDefined();
});
+ it('should render the solid icon appearance when available', async () => {
+ await (customElements.get(Icon.metadata.tag) as typeof Icon).add({
+ 'test-appearance': { svg: () => '' },
+ 'test-appearance-solid': { svg: () => '' }
+ });
+
+ removeFixture(fixture);
+ // eslint-disable-next-line @nvidia-elements/lint/no-unexpected-attribute-value
+ fixture = await createFixture(html``);
+ const el = fixture.querySelector(Icon.metadata.tag);
+ await elementIsStable(el);
+
+ expect(el.appearance).toBe('solid');
+ expect(el.shadowRoot.innerHTML).toContain('test-appearance-solid');
+ });
+
+ it('should fall back to the outline icon when a solid appearance is unavailable', async () => {
+ await (customElements.get(Icon.metadata.tag) as typeof Icon).add({
+ 'test-outline-only': { svg: () => '' }
+ });
+
+ removeFixture(fixture);
+ // eslint-disable-next-line @nvidia-elements/lint/no-unexpected-attribute-value
+ fixture = await createFixture(html``);
+ const el = fixture.querySelector(Icon.metadata.tag);
+ await elementIsStable(el);
+
+ expect(el.shadowRoot.innerHTML).toContain('test-outline-only');
+ expect(el.shadowRoot.innerHTML).not.toContain('test-outline-only-solid');
+ });
+
+ it('should not reflect the default outline appearance', async () => {
+ expect(element.appearance).toBeUndefined();
+ expect(element.hasAttribute('appearance')).toBe(false);
+
+ element.appearance = 'outline';
+ await elementIsStable(element);
+
+ expect(element.hasAttribute('appearance')).toBe(false);
+ });
+
it('should requestUpdate when new icon is registered', async () => {
const spy = vi.spyOn(element, 'requestUpdate');
element.name = 'test-svg-request-update' as IconName;
@@ -188,6 +229,30 @@ describe(Icon.metadata.tag, () => {
window.fetch = original;
});
+ it('should ignore stale SVG loads after the icon name changes', async () => {
+ const first = Promise.withResolvers();
+ const second = Promise.withResolvers();
+ const original = window.fetch;
+ window.fetch = vi.fn().mockImplementation((name: string) =>
+ Promise.resolve({ text: () => (name === 'first.svg' ? first.promise : second.promise) })
+ );
+
+ element.name = 'first.svg' as IconName;
+ await element.updateComplete;
+ element.name = 'second.svg' as IconName;
+ await element.updateComplete;
+
+ second.resolve('');
+ await elementIsStable(element);
+ first.resolve('');
+ await new Promise(resolve => setTimeout(resolve));
+ await elementIsStable(element);
+
+ expect(element.shadowRoot.innerHTML).toContain('id="second"');
+ expect(element.shadowRoot.innerHTML).not.toContain('id="first"');
+ window.fetch = original;
+ });
+
it('should dispatch event with icons detail when adding icons', async () => {
const iconName = 'test-svg-with-detail';
let receivedDetail: unknown;
diff --git a/projects/core/src/icon/icon.ts b/projects/core/src/icon/icon.ts
index fd466e3dd..20f7552cf 100644
--- a/projects/core/src/icon/icon.ts
+++ b/projects/core/src/icon/icon.ts
@@ -27,6 +27,7 @@ declare global {
* @cssprop --color
* @cssprop --width
* @cssprop --height
+ * @attr appearance - Selects the outline or solid form of a named icon.
* @slot - Custom SVG content to override the named icon
* @aria https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img
*/
@@ -46,6 +47,12 @@ export class Icon extends LitElement {
*/
@property({ type: String, reflect: true }) direction?: 'up' | 'down' | 'left' | 'right';
+ /**
+ * Selects the outline or solid form of the named icon. Solid icons use an optional `-solid` asset and fall back to
+ * the outline form when that asset is unavailable.
+ */
+ @property({ type: String }) appearance?: 'outline' | 'solid';
+
/**
* The name of the icon SVG sprite to render.
*/
@@ -80,12 +87,26 @@ export class Icon extends LitElement {
/** @private */
declare _internals: ElementInternals;
+ get #resolvedIconName() {
+ if (!this.name || this.name.endsWith('.svg') || this.appearance !== 'solid' || this.name.endsWith('-solid')) {
+ return this.name;
+ }
+
+ const solidName = `${this.name}-solid`;
+ return Icon._iconsRegistry[solidName] ? solidName : this.name;
+ }
+
get #iconString() {
- return isServer && globalThis._NVE_SSR_ICON_REGISTRY ? globalThis._NVE_SSR_ICON_REGISTRY[this.name!] : this.svg;
+ const iconName = this.#resolvedIconName;
+ return isServer && globalThis._NVE_SSR_ICON_REGISTRY && iconName
+ ? globalThis._NVE_SSR_ICON_REGISTRY[iconName]
+ : this.svg;
}
#iconRegistryEventName?: string;
+ #renderRequest = 0;
+
#onIconRegistryUpdate = (event: Event) => this.#asyncRender(event as CustomEvent);
render() {
@@ -133,7 +154,7 @@ export class Icon extends LitElement {
async updated(props: PropertyValues) {
super.updated(props);
- if (props.has('name')) {
+ if (props.has('name') || props.has('appearance')) {
this.#removeIconRegistryListener();
this.#addIconRegistryListener();
}
@@ -141,8 +162,9 @@ export class Icon extends LitElement {
}
#addIconRegistryListener() {
- if (!this.isConnected || !this.name || this.#iconRegistryEventName) return;
- this.#iconRegistryEventName = `${Icon.metadata.tag}-${this.name}`;
+ const iconName = this.#resolvedIconName;
+ if (!this.isConnected || !iconName || this.#iconRegistryEventName) return;
+ this.#iconRegistryEventName = `${Icon.metadata.tag}-${iconName}`;
globalThis.document?.addEventListener(this.#iconRegistryEventName, this.#onIconRegistryUpdate);
}
@@ -159,11 +181,14 @@ export class Icon extends LitElement {
}
async #render() {
- if (!this.name) return;
- const svg = await (this.name.endsWith('.svg')
- ? fetch(this.name).then(res => res.text())
- : (Icon._iconsRegistry[this.name]?.svg() ?? Promise.resolve('')));
- Icon._iconsRegistry[this.name] = { svg: () => svg, ...Icon._iconsRegistry[this.name] };
+ const renderRequest = ++this.#renderRequest;
+ const iconName = this.#resolvedIconName;
+ if (!iconName) return;
+ const svg = await (iconName.endsWith('.svg')
+ ? fetch(iconName).then(res => res.text())
+ : (Icon._iconsRegistry[iconName]?.svg() ?? Promise.resolve('')));
+ if (renderRequest !== this.#renderRequest) return;
+ Icon._iconsRegistry[iconName] = { svg: () => svg, ...Icon._iconsRegistry[iconName] };
this.svg = svg;
}
}
diff --git a/projects/core/src/tag/tag.test.lighthouse.ts b/projects/core/src/tag/tag.test.lighthouse.ts
index 908358461..cac43b4a1 100644
--- a/projects/core/src/tag/tag.test.lighthouse.ts
+++ b/projects/core/src/tag/tag.test.lighthouse.ts
@@ -16,6 +16,6 @@ describe('tag lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.kb).toBeLessThan(19.1);
+ expect(report.payload.javascript.kb).toBeLessThan(19.2);
});
});
diff --git a/projects/media/src/seek-button/seek-button.test.lighthouse.ts b/projects/media/src/seek-button/seek-button.test.lighthouse.ts
index 01d69dd0e..791aa0e5c 100644
--- a/projects/media/src/seek-button/seek-button.test.lighthouse.ts
+++ b/projects/media/src/seek-button/seek-button.test.lighthouse.ts
@@ -16,6 +16,6 @@ describe('media seek button lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.kb).toBeLessThan(21);
+ expect(report.payload.javascript.kb).toBeLessThan(21.1);
});
});
diff --git a/projects/site/src/docs/elements/icon.md b/projects/site/src/docs/elements/icon.md
index f8568f155..a0bfd5e1c 100644
--- a/projects/site/src/docs/elements/icon.md
+++ b/projects/site/src/docs/elements/icon.md
@@ -36,6 +36,13 @@ See the searchable [Interactive Icon Catalog](/docs/foundations/iconography/)
{% example '@nvidia-elements/core/icon/icon.examples.json' 'Direction' %}
+## Appearance
+
+Set `appearance="solid"` to render the optional solid asset for a named icon. When no `-solid` asset is available,
+the icon renders its outline form. Omit the attribute, or set `appearance="outline"`, to render the outline form.
+
+{% api 'nve-icon', 'property', 'appearance' %}
+
## Themes
{% example '@nvidia-elements/core/icon/icon.examples.json' 'Themes' %}