From 9c4f9f9e88a42ea2b233d6f3cb3652a2ab177f7f Mon Sep 17 00:00:00 2001 From: Cormac Rada Date: Thu, 13 Aug 2026 16:54:19 -0700 Subject: [PATCH] feat(core): add icon appearances --- projects/core/src/icon/icon.test.ts | 65 +++++++++++++++++++ projects/core/src/icon/icon.ts | 43 +++++++++--- projects/core/src/tag/tag.test.lighthouse.ts | 2 +- .../seek-button.test.lighthouse.ts | 2 +- projects/site/src/docs/elements/icon.md | 7 ++ 5 files changed, 108 insertions(+), 11 deletions(-) diff --git a/projects/core/src/icon/icon.test.ts b/projects/core/src/icon/icon.test.ts index 0a345712ad..8c531b0ffa 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 fd466e3ddf..20f7552cfe 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 908358461f..cac43b4a18 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 01d69dd0ef..791aa0e5cb 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 f8568f155c..a0bfd5e1c1 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' %}