Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "fix: remove empty text nodes from avatar",
"packageName": "@fluentui/web-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
8 changes: 8 additions & 0 deletions packages/web-components/docs/web-components.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,10 +465,18 @@ export class BaseAnchor extends FASTElement {
// @public
export class BaseAvatar extends FASTElement {
constructor();
// (undocumented)
connectedCallback(): void;
// @internal
defaultSlot: HTMLSlotElement;
// (undocumented)
disconnectedCallback(): void;
// @internal
elementInternals: ElementInternals;
initials?: string | undefined;
name?: string | undefined;
// @internal
slotchangeHandler(): void;
}

// @public
Expand Down
60 changes: 59 additions & 1 deletion packages/web-components/src/avatar/avatar.base.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { attr, FASTElement } from '@microsoft/fast-element';
import { attr, FASTElement, Updates } from '@microsoft/fast-element';

/**
* The base class used for constructing a fluent-avatar custom element
* @public
*/
export class BaseAvatar extends FASTElement {
/**
* Signal to remove event listeners when the component is disconnected.
*
* @internal
*/
private abortSignal?: AbortController;

/**
* Reference to the default slot element.
* @internal
*/
public defaultSlot!: HTMLSlotElement;

/**
* The internal {@link https://developer.mozilla.org/docs/Web/API/ElementInternals | `ElementInternals`} instance for the component.
*
Expand Down Expand Up @@ -32,9 +45,54 @@ export class BaseAvatar extends FASTElement {
@attr
public initials?: string | undefined;

connectedCallback(): void {
super.connectedCallback();
this.slotchangeHandler();
}

constructor() {
super();

this.elementInternals.role = 'img';
}

disconnectedCallback(): void {
this.abortSignal?.abort();
this.abortSignal = undefined;

super.disconnectedCallback();
}

/**
* Removes any empty text nodes from the default slot when the slotted content changes.
*
* @param e - The event object
* @internal
*/
public slotchangeHandler(): void {
this.normalize();

const elements = this.defaultSlot.assignedElements();

if (!elements.length && !this.innerText.trim()) {
const nodes = this.defaultSlot.assignedNodes() as Element[];

nodes
.filter(node => node.nodeType === Node.TEXT_NODE)
.forEach(node => {
this.removeChild(node);
});
}

Updates.enqueue(() => {
if (!this.abortSignal || this.abortSignal.signal.aborted) {
this.abortSignal = new AbortController();
}

this.defaultSlot.addEventListener('slotchange', () => this.slotchangeHandler(), {
once: true,
signal: this.abortSignal.signal,
});
});
}
}
27 changes: 27 additions & 0 deletions packages/web-components/src/avatar/avatar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,27 @@ test.describe('Avatar', () => {
await expect(element).toHaveJSProperty('elementInternals.role', 'img');
});

test('should render an icon when no name or initials are provided', async ({ fastPage }) => {
const { element } = fastPage;
const icon = element.locator('svg');

await fastPage.setTemplate({ innerHTML: `\n\n\n` });

await expect(icon).toBeVisible();

await test.step('should NOT render the icon when empty elements are present in the default slot', async () => {
await fastPage.setTemplate({ innerHTML: `<div></div><span></span>\n\n` });

await expect(icon).toBeHidden();
});

await test.step('should retain comment nodes in the default slot when no name or initials are provided', async () => {
await fastPage.setTemplate({ innerHTML: `\n<!--hello-->\n<!--world-->\n` });

await expect(icon).toBeVisible();
});
});

test('When no name value is set, should render with custom initials based on the provided initials value', async ({
fastPage,
}) => {
Expand All @@ -36,6 +57,12 @@ test.describe('Avatar', () => {
await fastPage.setTemplate({ attributes: { initials: 'JD' } });

await expect(element).toContainText('JD');

await test.step('should NOT render the default icon', async () => {
const icon = element.locator('svg');

await expect(icon).toBeHidden();
});
});

test('When name value is set, should generate initials based on the provided name value', async ({ fastPage }) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/web-components/src/avatar/avatar.template.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ElementViewTemplate, html } from '@microsoft/fast-element';
import { type ElementViewTemplate, html, ref } from '@microsoft/fast-element';
import type { Avatar } from './avatar.js';

const defaultIconTemplate = html`<svg
Expand All @@ -21,7 +21,7 @@ const defaultIconTemplate = html`<svg
*/
export function avatarTemplate<T extends Avatar>(): ElementViewTemplate<T> {
return html<T>`
<slot>${x => (x.name || x.initials ? x.generateInitials() : defaultIconTemplate)}</slot>
<slot ${ref('defaultSlot')}>${x => (x.name || x.initials ? x.generateInitials() : defaultIconTemplate)}</slot>
<slot name="badge"></slot>
`;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/web-components/src/avatar/avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ export class Avatar extends BaseAvatar {
const size = this.size ?? 32;

return (
this.initials ??
getInitials(this.name, window.getComputedStyle(this as unknown as HTMLElement).direction === 'rtl', {
this.initials ||
getInitials(this.name, window.getComputedStyle(this).direction === 'rtl', {
firstInitialOnly: size <= 16,
})
);
Expand Down