Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d2a0364
feat(button): show only the icon and vertical badges in icon-only but…
thetaPC Oct 23, 2026
db6eca1
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 26, 2026
3c8aba6
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 26, 2026
ba5108a
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 26, 2026
6c03447
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 26, 2026
554b0cb
fix(button): hide icon-only text through the default slot instead of …
thetaPC Oct 26, 2026
638bfbc
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 26, 2026
736ff49
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 27, 2026
22d07b4
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 27, 2026
8878102
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 27, 2026
ba8c040
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 27, 2026
41f7635
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 27, 2026
de1c6ae
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 28, 2026
339daa0
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 28, 2026
0700826
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 28, 2026
483ae2f
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 28, 2026
a8cf631
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 28, 2026
e371569
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 28, 2026
791e695
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 28, 2026
9f6aee1
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 28, 2026
5feb0ce
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 28, 2026
ec7aeb9
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 29, 2026
dd49a78
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 29, 2026
73bcbf1
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Oct 29, 2026
8b8d804
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Nov 5, 2026
22a4ad5
fix(button): skip the start and end slots in icon-only buttons
thetaPC Nov 5, 2026
a9d788d
fix(button): keep wrapped icon-only text in the accessibility tree
thetaPC Nov 5, 2026
b68068b
Merge remote-tracking branch 'origin/FW-6838-badge-spacing' into FW-6…
thetaPC Nov 5, 2026
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
11 changes: 6 additions & 5 deletions BREAKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,12 @@ The following breaking changes apply to `ion-button`:
6. The `md` button follows Material Design 2 for its spacing, outline, elevation and disabled state. <sup>[6](#version-10x-button-md-material-design-2)</sup>
7. The `ios` button follows iOS 17 for its font, icons and disabled state. <sup>[7](#version-10x-button-ios-17)</sup>
8. A badge without a `vertical` position now has a gap between it and the label, and no longer makes the button taller than its `min.height`. To remove the gap, set `IonButton.size.<size>.badge.margin.start` for global styles or `--ion-button-size-<size>-badge-margin-start` for component-specific styles to `0`.
9. CSS variables have been replaced. <sup>[9](#version-10x-button-replaced-css-variables)</sup>
10. `--padding-*` CSS variables are no longer part of the documented public API (but remain functional). <sup>[10](#version-10x-button-padding-variables)</sup>
11. The fill, size, shape and expand classes have been renamed. <sup>[11](#version-10x-button-renamed-classes)</sup>
12. The `default` size has been deprecated. <sup>[12](#version-10x-button-deprecated-default-size)</sup>
13. Theme classes (`ion-button.md`, `ion-button.ios`) are no longer supported. <sup>[13](#version-10x-button-theme-classes)</sup>
9. A button with an icon in the `icon-only` slot now shows only that icon and any badge with a `vertical` position. Text, elements in the `start` and `end` slots, and other content are hidden. Text and elements without a slot are only visually hidden, so they still name the button for assistive technology. To show other content, remove the `icon-only` slot and use the `start` or `end` slot for the icon.
10. CSS variables have been replaced. <sup>[10](#version-10x-button-replaced-css-variables)</sup>
11. `--padding-*` CSS variables are no longer part of the documented public API (but remain functional). <sup>[11](#version-10x-button-padding-variables)</sup>
12. The fill, size, shape and expand classes have been renamed. <sup>[12](#version-10x-button-renamed-classes)</sup>
13. The `default` size has been deprecated. <sup>[13](#version-10x-button-deprecated-default-size)</sup>
14. Theme classes (`ion-button.md`, `ion-button.ios`) are no longer supported. <sup>[14](#version-10x-button-theme-classes)</sup>

<h5 id="version-10x-button-md-material-design-2">Material Design 2 in <code>md</code></h5>

Expand Down
16 changes: 16 additions & 0 deletions core/src/components/button/button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,22 @@ $spinner-sizes: xsmall, small, medium, large, xlarge;
}
}

// An icon-only button shows only its icon and badges with a `vertical` position.
// Other content stays in the accessibility tree, so wrapped text still names the button.
:host(.button-has-icon-only) ::slotted(:not([slot]):not(ion-badge[vertical])) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This one worries me a bit, because text wrapped in an element loses its accessible name now. With <span>Add item</span> or <ion-label>Add item</ion-label> next to the icon, the button has an empty name, where before it was "Add item", and only bare text still works. A visually hidden span is a pretty common way to label an icon button, so could the default-slot elements be visually hidden instead of display: none? If not, BREAKING.md should say only bare text names the button, since right now it says hidden text still does. A test with wrapped text would catch this either way.

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.

Good catch. In a9d788d, content without a slot is visually hidden instead of display: none, so <span> and <ion-label> name the button again. BREAKING.md and the icon-only slot docs now say it's visually hidden. The test covers bare text, <span> and <ion-label>, and fails on the old code.

One side effect: an inline badge now adds to the name, the same way a vertical badge already did.

@include mixins.visually-hidden();
}

/**
* Text can't be selected, so the default slot passes it a zero font size.
* Letter spacing is inherited in px, so it's reset too.
*/
:host(.button-has-icon-only) slot:not([name]) {
font-size: 0;

letter-spacing: 0;
}

// Button: Slotted Elements
// --------------------------------------------------

Expand Down
7 changes: 4 additions & 3 deletions core/src/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type { IonButtonExpand, IonButtonFill, IonButtonShape, IonButtonSize } fr
* @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component.
*
* @slot - Content is placed between the named slots if provided without a slot.
* @slot icon-only - Should be used on an icon in a button that has no text.
* @slot icon-only - An icon shown on its own, with any badge that has a `vertical` position. Text and elements without a slot are visually hidden, so they can still name the button for assistive technology. Content in the `start` and `end` slots isn't rendered.
* @slot start - Content is placed to the left of the button text in LTR, and to the right in RTL.
* @slot end - Content is placed to the right of the button text in LTR, and to the left in RTL.
*
Expand Down Expand Up @@ -521,9 +521,10 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
>
<span class="button-inner">
<slot name="icon-only" onSlotchange={this.iconOnlySlotChanged}></slot>
<slot name="start"></slot>
{/* An icon-only button skips these slots, so outside styles can't show their content */}
{!hasIconOnly && <slot name="start"></slot>}
<slot onSlotchange={this.onSlotChanged}></slot>
<slot name="end"></slot>
{!hasIconOnly && <slot name="end"></slot>}
</span>
{showRipple && <ion-ripple-effect type={this.rippleType}></ion-ripple-effect>}
</TagType>
Expand Down
55 changes: 55 additions & 0 deletions core/src/components/button/test/icon/button.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,58 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf
});
});
});

/**
* This behavior does not vary across modes or directions.
* The ionic theme's link styles set `display`, so it also
* checks that outside styles can't show hidden content.
*/
configs({ modes: ['ionic-md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('button: icon only'), () => {
test('should only show the icon and badges with a vertical position', async ({ page }) => {
await page.setContent(
`
<ion-button id="plain"><ion-icon slot="icon-only" name="add"></ion-icon></ion-button>
<ion-button id="extra">
<a slot="start" href="#">Link</a>
<ion-icon slot="icon-only" name="add"></ion-icon>
Add item
<ion-badge id="inline-badge">3</ion-badge>
<ion-badge id="vertical-badge" vertical="top">3</ion-badge>
<ion-icon slot="end" name="star"></ion-icon>
</ion-button>
`,
config
);

const extra = page.locator('#extra');

await expect(extra.locator('[slot="start"]')).toBeHidden();
await expect(extra.locator('[slot="end"]')).toBeHidden();
// Content without a slot is visually hidden, so it still names the button
await expect(page.locator('#inline-badge')).toHaveCSS('opacity', '0');
await expect(page.locator('#vertical-badge')).toBeVisible();

// The hidden text takes no space, so the button stays the size of a plain icon-only button
const plainBox = await page.locator('#plain').boundingBox();
const extraBox = await extra.boundingBox();
expect(extraBox!.width).toBe(plainBox!.width);
expect(extraBox!.height).toBe(plainBox!.height);
});

test('should use hidden text as the accessible name', async ({ page }) => {
await page.setContent(
`
<ion-button id="bare"><ion-icon slot="icon-only" name="add"></ion-icon>Add item</ion-button>
<ion-button id="span"><ion-icon slot="icon-only" name="add"></ion-icon><span>Add item</span></ion-button>
<ion-button id="label"><ion-icon slot="icon-only" name="add"></ion-icon><ion-label>Add item</ion-label></ion-button>
`,
config
);

for (const id of ['bare', 'span', 'label']) {
await expect(page.locator(`#${id}`).getByRole('button')).toHaveAccessibleName('Add item');
}
});
});
});
Loading