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
9 changes: 9 additions & 0 deletions .changeset/fix-action-button-avatar-icon-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@adobe/spectrum-wc': patch
---

**fix(action-button):** A `<swc-avatar>` slotted into `<swc-action-button>`'s `icon` slot now automatically scales to match the button's icon size.

Previously, Avatar's own `size` attribute controlled its rendered size regardless of the action button's size, since Avatar's `:host([size])` sizing rule won the specificity contest with the icon slot's generic sizing rule for the avatar's _host box_ only — the visible image inside Avatar's shadow root ignored that squeeze entirely and rendered at its own `size`, causing it to overflow. Consumers previously had to manually pair an avatar `size` with the action-button `size` (e.g. `xl` action button with avatar `size="1000"`) to avoid a visual mismatch.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a VRT run.

@rubencarvalho rubencarvalho Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great call! I even added this to the vrt permutations :)

@rubencarvalho rubencarvalho Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad that I did because I found another bug!!!!

Lit still leaves its child-part boundary marker, a comment node <!--?lit$…$--> with non-empty data as a direct light-DOM child of <swc-action-button>. With no slot attribute, it lands in the default slot.

Then in slot-text-controller.ts, checkInitialContent() classifies nodes as element-or-text:

if (el.tagName) {  }              // elements
if (this.slotName) return false;   // named slots
return node.textContent ? node.textContent.trim().length > 0 : false;  // ← comment falls here

A comment node has no tagName, so it hits the last branch, and node.textContent returns the comment's data (?lit$…$) → trims to non-empty → hasLabel = true → iconOnly = false.


The icon slot now also sets `--swc-avatar-size` directly (the same way it already sets its own internal icon-size custom properties), which wins over Avatar's own `:host([size])` rule for a slotted avatar specifically because of how shadow-tree custom property cascading resolves rules matching via `::slotted()` against rules from the slotted element's own shadow tree. Avatar's `size` attribute becomes a no-op when slotted into an action button's icon slot; standalone avatar sizing is unaffected.
8 changes: 8 additions & 0 deletions .changeset/fix-slot-text-controller-comment-nodes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@adobe/spectrum-wc-core': patch
'@adobe/spectrum-wc': patch
---

**fix(slot-text-controller):** `SlotTextController` no longer counts comment nodes as label content. A `${cond ? nothing : label}` binding leaves a Lit child-part marker (a comment node with non-empty data) in the default slot; its data was previously misread as label text during the controller's initial `host.childNodes` scan.

For a consumer whose default slot does not bind `@slotchange` (for example an icon-only `<swc-action-button>` with a conditional label), this made `hasContent` stay `true`, so the icon-only presentation was never applied. Only real text nodes now count.
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,13 @@ export class SlotTextController implements ReactiveController {
if (this.slotName) {
return false;
}
return node.textContent ? node.textContent.trim().length > 0 : false;
// Only real text nodes count. Comment nodes (e.g. Lit's child-part
// markers left behind by a `${cond ? nothing : label}` binding) also
// lack a `tagName`, and their `textContent` is the marker data, which
// would otherwise be misread as label text.
return node.nodeType === Node.TEXT_NODE && node.textContent
? node.textContent.trim().length > 0
: false;
});

if (hasContent !== this._hasContent) {
Expand Down Expand Up @@ -164,7 +170,13 @@ export class SlotTextController implements ReactiveController {
if (this.slotName) {
return false;
}
return node.textContent ? node.textContent.trim().length > 0 : false;
// Only real text nodes count. Comment nodes (e.g. Lit's child-part
// markers left behind by a `${cond ? nothing : label}` binding) also
// lack a `tagName`, and their `textContent` is the marker data, which
// would otherwise be misread as label text.
return node.nodeType === Node.TEXT_NODE && node.textContent
? node.textContent.trim().length > 0
: false;
});
this._hasContent = relevant.length > 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ declare global {
interface HTMLElementTagNameMap {
'demo-slot-text-host': DemoSlotTextHost;
'demo-slot-text-named-host': DemoSlotTextNamedHost;
'demo-slot-text-static-host': DemoSlotTextStaticHost;
}
}

Expand Down Expand Up @@ -90,6 +91,26 @@ export class DemoSlotTextHost extends AbstractSlotTextHost {
}
}

/**
* @internal
*
* Storybook/test-only host that does **not** bind `@slotchange` on its default
* slot, mirroring a consumer like `swc-action-button` whose label detection
* therefore relies solely on the controller's initial `host.childNodes` scan.
* Used to verify that a comment node (e.g. a Lit `${cond ? nothing : label}`
* child-part marker) is not miscounted as label content — a case the
* slotchange path masks because comment nodes are not slottable, so
* `assignedNodes()` never returns them.
*/
@customElement('demo-slot-text-static-host')
export class DemoSlotTextStaticHost extends AbstractSlotTextHost {
protected override render(): TemplateResult {
return html`
<slot></slot>
`;
}
}

/**
* @internal
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,17 @@
* `hasContent` current as content is added, removed, or edited in place.
*/

import { html } from 'lit';
import { html, nothing } from 'lit';
import { expect } from '@storybook/test';
import type { Meta, StoryObj as Story } from '@storybook/web-components';

import './demo-hosts.js';

import type { DemoSlotTextHost, DemoSlotTextNamedHost } from './demo-hosts.js';
import type {
DemoSlotTextHost,
DemoSlotTextNamedHost,
DemoSlotTextStaticHost,
} from './demo-hosts.js';
import textMeta from './slot-text-controller.stories.js';

// ─────────────────────────
Expand Down Expand Up @@ -168,6 +172,51 @@ export const DynamicContentTest: Story = {
};
DynamicContentTest.storyName = 'Dynamic content';

// ──────────────────────────────────────────────────────────────────────────
// Comment markers in the default slot are not content
// ──────────────────────────────────────────────────────────────────────────

export const CommentMarkerTest: Story = {
// A `${cond ? nothing : label}` binding leaves a Lit child-part marker
// (a comment node with non-empty data) as a light-DOM child of the host,
// assigned to the default slot. It must not be misread as label text — the
// regression that made an icon-only `swc-action-button` (avatar in the icon
// slot, conditional label) fail to apply its icon-only presentation.
//
// Uses the static host (no `@slotchange`) so the controller's initial
// `host.childNodes` scan is the only signal — matching action-button. The
// slotchange path would mask the bug, since comment nodes are not slottable
// and never appear in `assignedNodes()`.
render: () => {
const showLabel = false;
return html`
<demo-slot-text-static-host>
${showLabel ? 'Verified' : nothing}
</demo-slot-text-static-host>
`;
},
play: async ({ canvasElement, step }) => {
const host = canvasElement.querySelector<DemoSlotTextStaticHost>(
'demo-slot-text-static-host'
);
if (!host) {
throw new Error('demo-slot-text-static-host not found');
}
await host.updateComplete;

await step(
'a Lit comment marker in the default slot is not counted as content',
async () => {
expect(
host.hasContent,
'hasContent is false when only a comment marker is present'
).toBe(false);
}
);
},
};
CommentMarkerTest.storyName = 'Comment marker is not content';

// ──────────────────────────────────────────────────────────────────────────
// Named slot ignores bare text nodes
// ──────────────────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ slot[name="icon"]::slotted(*) {
fill: currentcolor;
}

/* Lets a slotted <swc-avatar> track the icon slot's own size instead of
requiring consumers to pair its size attribute with the button's size. */
slot[name="icon"]::slotted(swc-avatar) {
--swc-avatar-size: var(--_swc-action-button-icon-size);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps make this more explicitly scoped to ::slotted(swc-avatar)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applied the recommendation!

}

/* ── Sizes ────────────────────────────────────────── */

:host([size="xs"]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import { html } from 'lit';
import type { Meta, StoryObj as Story } from '@storybook/web-components';

import '@adobe/spectrum-wc/components/avatar/swc-avatar.js';

import {
FORCED_STATES,
forcePseudoStates,
Expand Down Expand Up @@ -63,6 +65,22 @@ const asLinkAndButton = (classes: string, label: string) => [
`,
];

// A <swc-avatar> carrying the `.swc-ActionButton-icon` class, which the global
// stylesheet gives `--swc-avatar-size: var(--_swc-action-button-icon-size)` so
// the avatar tracks the button size — the class-based counterpart to the
// shadow-DOM ::slotted rule covered in action-button.vrt.ts. A fixed picsum id
// keeps the image deterministic for Chromatic (matching the docs stories).
const AVATAR_ICON_SRC = 'https://picsum.photos/id/64/500/500';

const globalAvatarIcon = html`
<swc-avatar
class="swc-ActionButton-icon"
src=${AVATAR_ICON_SRC}
alt=""
decorative
></swc-avatar>
`;

// Medium is the default (no size class); the rest map to their modifier class.
const SIZE_CASES = [
{ classes: 'swc-ActionButton--sizeXs', label: 'Extra-small' },
Expand Down Expand Up @@ -126,6 +144,33 @@ const globalStylesContent = () => html`
],
'Anatomy'
)}
${row(
[
...SIZE_CASES.map(
({ classes, label }) => html`
<button
type="button"
class="swc-ActionButton swc-ActionButton--hasIcon ${classes}"
>
${globalAvatarIcon}
<span class="swc-ActionButton-label">${label}</span>
</button>
`
),
...SIZE_CASES.map(
({ classes }) => html`
<button
type="button"
class="swc-ActionButton swc-ActionButton--iconOnly ${classes}"
aria-label="Jane Doe"
>
${globalAvatarIcon}
</button>
`
),
],
'Avatar icon'
)}
${staticColorBackground(
row(
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* governing permissions and limitations under the License.
*/

import { html } from 'lit';
import { html, nothing } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import type { Meta, StoryObj as Story } from '@storybook/web-components';

import {
Expand All @@ -19,6 +20,7 @@ import {
} from '@adobe/spectrum-wc-core/components/action-button';

import '@adobe/spectrum-wc/components/action-button/swc-action-button.js';
import '@adobe/spectrum-wc/components/avatar/swc-avatar.js';

import {
createPermutations,
Expand Down Expand Up @@ -156,6 +158,33 @@ const renderIconOnlyPermutation = ({
</swc-action-button>
`;

// A slotted <swc-avatar> as the icon. action-button.css sets
// `--swc-avatar-size: var(--_swc-action-button-icon-size)` on the icon slot, so
// the avatar tracks each button size without the consumer restating a size on
// the avatar. Written as direct markup (not template()) for the same icon-slot
// parsing reason as icon-only above, and rendered across every size because
// size is the axis this scaling actually drives. A fixed picsum id keeps the
// image deterministic for Chromatic (matching the docs stories).
const AVATAR_ICON_SRC = 'https://picsum.photos/id/64/500/500';

const renderAvatarActionButton = (
size: (typeof ACTION_BUTTON_VALID_SIZES)[number],
iconOnly: boolean
) => html`
<swc-action-button
size=${size}
accessible-label=${ifDefined(iconOnly ? 'Jane Doe' : undefined)}
>
<swc-avatar
slot="icon"
src=${AVATAR_ICON_SRC}
alt=""
decorative
></swc-avatar>
${iconOnly ? nothing : 'Jane Doe'}
</swc-action-button>
`;

const forceActionButtonStates = forcePseudoStates(
'swc-action-button[data-force-state]',
'.swc-ActionButton'
Expand Down Expand Up @@ -185,6 +214,17 @@ const permutationContent = () => html`
ICON_ONLY_PERMUTATIONS.map(renderIconOnlyPermutation),
'Icon-only anatomy'
)}
${row(
[
...ACTION_BUTTON_VALID_SIZES.map((size) =>
renderAvatarActionButton(size, false)
),
...ACTION_BUTTON_VALID_SIZES.map((size) =>
renderAvatarActionButton(size, true)
),
],
'Avatar icon'
)}
${row(
[
html`
Expand Down
2 changes: 2 additions & 0 deletions 2nd-gen/packages/swc/components/avatar/avatar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ A disabled avatar indicates that the entity is not currently active or available

An avatar can be placed inside an action button to create a user-triggered action tied to a specific person or entity.

When slotted into the `icon` slot, the avatar automatically scales to match the action button's own icon size; the avatar's own `size` attribute has no effect in that context.

<Canvas of={AvatarStories.InActionButton} />

## Accessibility
Expand Down
30 changes: 17 additions & 13 deletions 2nd-gen/packages/swc/components/avatar/stories/avatar.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Avatar } from '@adobe/spectrum-wc/avatar';
import { AVATAR_VALID_SIZES } from '@adobe/spectrum-wc-core/components/avatar/index.js';

import '@adobe/spectrum-wc/components/avatar/swc-avatar.js';
import '@adobe/spectrum-wc/components/action-button/swc-action-button.js';

// ────────────────
// METADATA
Expand Down Expand Up @@ -231,26 +232,29 @@ export const Disabled: Story = {
// ──────────────────────────────

export const InActionButton: Story = {
// TODO: Replace <button> with <swc-action-button> once that component is migrated to 2nd-gen.
render: (args) => html`
<button
type="button"
style="display:inline-flex;align-items:center;gap:8px;padding:4px 12px;cursor:pointer;"
>
<swc-avatar
src=${args.src}
alt=${args.alt}
size=${args.size}
></swc-avatar>
<swc-action-button>
<swc-avatar slot="icon" src=${args.src} alt="" decorative></swc-avatar>
Jane Doe
</button>
</swc-action-button>
<swc-action-button accessible-label="Jane Doe">
<swc-avatar slot="icon" src=${args.src} alt="" decorative></swc-avatar>
</swc-action-button>
<swc-action-button size="xl">
<swc-avatar slot="icon" src=${args.src} alt="" decorative></swc-avatar>
Jane Doe
</swc-action-button>
<swc-action-button size="xl" accessible-label="Jane Doe">
<swc-avatar slot="icon" src=${args.src} alt="" decorative></swc-avatar>
</swc-action-button>
`,
args: {
src: PLACEHOLDER_SRC,
alt: 'Jane Doe',
size: '100',
},
tags: ['behaviors'],
parameters: {
flexLayout: 'row-wrap',
},
};

// ────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
.swc-ActionButton-icon {
color: inherit;
fill: currentcolor;
--swc-avatar-size: var(--_swc-action-button-icon-size);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did this come from the generator or hand generated?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

global-component styles are auto-generated!

}

.swc-ActionButton--sizeXs {
Expand Down
Loading