From d2a03643d7670350da1c5db7bfb52c44808aedf6 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 23 Oct 2026 16:13:13 -0700 Subject: [PATCH 1/4] feat(button): show only the icon and vertical badges in icon-only buttons --- BREAKING.md | 11 +++-- core/src/components/button/button.scss | 22 +++++++++ core/src/components/button/button.tsx | 6 ++- .../components/button/test/icon/button.e2e.ts | 48 +++++++++++++++++++ 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/BREAKING.md b/BREAKING.md index 3b88c718922..6a58c284261 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -60,11 +60,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. [6](#version-10x-button-md-material-design-2) 7. The `ios` button follows iOS 17 for its font, icons and disabled state. [7](#version-10x-button-ios-17) 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..badge.margin.start` for global styles or `--ion-button-size--badge-margin-start` for component-specific styles to `0`. -9. CSS variables have been replaced. [9](#version-10x-button-replaced-css-variables) -10. `--padding-*` CSS variables are no longer part of the documented public API (but remain functional). [10](#version-10x-button-padding-variables) -11. The fill, size, shape and expand classes have been renamed. [11](#version-10x-button-renamed-classes) -12. The `default` size has been deprecated. [12](#version-10x-button-deprecated-default-size) -13. Theme classes (`ion-button.md`, `ion-button.ios`) are no longer supported. [13](#version-10x-button-theme-classes) +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. Hidden text still names 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. [10](#version-10x-button-replaced-css-variables) +11. `--padding-*` CSS variables are no longer part of the documented public API (but remain functional). [11](#version-10x-button-padding-variables) +12. The fill, size, shape and expand classes have been renamed. [12](#version-10x-button-renamed-classes) +13. The `default` size has been deprecated. [13](#version-10x-button-deprecated-default-size) +14. Theme classes (`ion-button.md`, `ion-button.ios`) are no longer supported. [14](#version-10x-button-theme-classes)
Material Design 2 in md
diff --git a/core/src/components/button/button.scss b/core/src/components/button/button.scss index c4862ebf36d..259ef176cea 100644 --- a/core/src/components/button/button.scss +++ b/core/src/components/button/button.scss @@ -183,6 +183,28 @@ $spinner-sizes: xsmall, small, medium, large, xlarge; } } +// An icon-only button shows only its icon and badges with a `vertical` position +:host(.button-has-icon-only) ::slotted([slot="start"]), +:host(.button-has-icon-only) ::slotted([slot="end"]), +:host(.button-has-icon-only) ::slotted(:not([slot]):not(ion-badge[vertical])) { + display: none; +} + +// Text can't be selected, so a zero font size hides it. Letter spacing is inherited in px, so it's reset too. +:host(.button-has-icon-only) .button-content { + font-size: 0; + + letter-spacing: 0; +} + +// Button: Content +// -------------------------------------------------- + +// Lays out the default slot's content as if this wrapper weren't there +.button-content { + display: contents; +} + // Button: Slotted Elements // -------------------------------------------------- diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index e3f1b33aac8..32475d01f4d 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -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 - Should be used on an icon in a button that has no text. The button then shows only this icon and any badge with a `vertical` position. * @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. * @@ -520,7 +520,9 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf - + + + {mode === 'md' && } diff --git a/core/src/components/button/test/icon/button.e2e.ts b/core/src/components/button/test/icon/button.e2e.ts index ad24f474098..3a45413780d 100644 --- a/core/src/components/button/test/icon/button.e2e.ts +++ b/core/src/components/button/test/icon/button.e2e.ts @@ -12,3 +12,51 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf }); }); }); + +/** + * This behavior does not vary across modes or directions + */ +configs({ modes: ['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( + ` + + + + + Add item + 3 + 3 + + + `, + config + ); + + const extra = page.locator('#extra'); + + await expect(extra.locator('[slot="start"]')).toBeHidden(); + await expect(extra.locator('[slot="end"]')).toBeHidden(); + await expect(page.locator('#inline-badge')).toBeHidden(); + 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( + ` + Add item + `, + config + ); + + await expect(page.getByRole('button')).toHaveAccessibleName('Add item'); + }); + }); +}); From 554b0cbe9f407276171b3bf602873f2da5b19736 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Mon, 26 Oct 2026 11:52:01 -0700 Subject: [PATCH 2/4] fix(button): hide icon-only text through the default slot instead of a wrapper --- core/src/components/button/button.scss | 15 +++++---------- core/src/components/button/button.tsx | 4 +--- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/core/src/components/button/button.scss b/core/src/components/button/button.scss index 85cad6706ce..1bdef86c19f 100644 --- a/core/src/components/button/button.scss +++ b/core/src/components/button/button.scss @@ -190,21 +190,16 @@ $spinner-sizes: xsmall, small, medium, large, xlarge; display: none; } -// Text can't be selected, so a zero font size hides it. Letter spacing is inherited in px, so it's reset too. -:host(.button-has-icon-only) .button-content { +/** + * 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: Content -// -------------------------------------------------- - -// Lays out the default slot's content as if this wrapper weren't there -.button-content { - display: contents; -} - // Button: Slotted Elements // -------------------------------------------------- diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index 32475d01f4d..4cb978a4d86 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -520,9 +520,7 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf - - - + {mode === 'md' && } From 22a4ad50c0de1d8759eed54ef963c94a8f29f02a Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Wed, 4 Nov 2026 19:59:47 -0800 Subject: [PATCH 3/4] fix(button): skip the start and end slots in icon-only buttons --- core/src/components/button/button.scss | 2 -- core/src/components/button/button.tsx | 5 +++-- core/src/components/button/test/icon/button.e2e.ts | 8 +++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/core/src/components/button/button.scss b/core/src/components/button/button.scss index ca4378cd4e7..53e7302bfc5 100644 --- a/core/src/components/button/button.scss +++ b/core/src/components/button/button.scss @@ -184,8 +184,6 @@ $spinner-sizes: xsmall, small, medium, large, xlarge; } // An icon-only button shows only its icon and badges with a `vertical` position -:host(.button-has-icon-only) ::slotted([slot="start"]), -:host(.button-has-icon-only) ::slotted([slot="end"]), :host(.button-has-icon-only) ::slotted(:not([slot]):not(ion-badge[vertical])) { display: none; } diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index 48716d20be9..d51b8a0728e 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -521,9 +521,10 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf > - + {/* An icon-only button skips these slots, so outside styles can't show their content */} + {!hasIconOnly && } - + {!hasIconOnly && } {showRipple && } diff --git a/core/src/components/button/test/icon/button.e2e.ts b/core/src/components/button/test/icon/button.e2e.ts index 3a45413780d..52e1cc079ea 100644 --- a/core/src/components/button/test/icon/button.e2e.ts +++ b/core/src/components/button/test/icon/button.e2e.ts @@ -14,16 +14,18 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf }); /** - * This behavior does not vary across modes or directions + * 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: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { +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( ` - + Link Add item 3 From a9d788d212264b774692457fe62d8d11b4ef98ab Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Wed, 4 Nov 2026 19:59:47 -0800 Subject: [PATCH 4/4] fix(button): keep wrapped icon-only text in the accessibility tree --- BREAKING.md | 2 +- core/src/components/button/button.scss | 5 +++-- core/src/components/button/button.tsx | 2 +- core/src/components/button/test/icon/button.e2e.ts | 11 ++++++++--- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/BREAKING.md b/BREAKING.md index 9de8403b4a0..973cc7211c2 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -63,7 +63,7 @@ The following breaking changes apply to `ion-button`: 6. The `md` button follows Material Design 2 for its spacing, outline, elevation and disabled state. [6](#version-10x-button-md-material-design-2) 7. The `ios` button follows iOS 17 for its font, icons and disabled state. [7](#version-10x-button-ios-17) 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..badge.margin.start` for global styles or `--ion-button-size--badge-margin-start` for component-specific styles to `0`. -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. Hidden text still names the button for assistive technology. To show other content, remove the `icon-only` slot and use the `start` or `end` slot for the icon. +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. [10](#version-10x-button-replaced-css-variables) 11. `--padding-*` CSS variables are no longer part of the documented public API (but remain functional). [11](#version-10x-button-padding-variables) 12. The fill, size, shape and expand classes have been renamed. [12](#version-10x-button-renamed-classes) diff --git a/core/src/components/button/button.scss b/core/src/components/button/button.scss index 53e7302bfc5..92ea3a02dd6 100644 --- a/core/src/components/button/button.scss +++ b/core/src/components/button/button.scss @@ -183,9 +183,10 @@ $spinner-sizes: xsmall, small, medium, large, xlarge; } } -// An icon-only button shows only its icon and badges with a `vertical` position +// 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])) { - display: none; + @include mixins.visually-hidden(); } /** diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index d51b8a0728e..a267fce190f 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -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. The button then shows only this icon and any badge with a `vertical` position. + * @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. * diff --git a/core/src/components/button/test/icon/button.e2e.ts b/core/src/components/button/test/icon/button.e2e.ts index 52e1cc079ea..a718637664e 100644 --- a/core/src/components/button/test/icon/button.e2e.ts +++ b/core/src/components/button/test/icon/button.e2e.ts @@ -40,7 +40,8 @@ configs({ modes: ['ionic-md'], directions: ['ltr'] }).forEach(({ title, config } await expect(extra.locator('[slot="start"]')).toBeHidden(); await expect(extra.locator('[slot="end"]')).toBeHidden(); - await expect(page.locator('#inline-badge')).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 @@ -53,12 +54,16 @@ configs({ modes: ['ionic-md'], directions: ['ltr'] }).forEach(({ title, config } test('should use hidden text as the accessible name', async ({ page }) => { await page.setContent( ` - Add item + Add item + Add item + Add item `, config ); - await expect(page.getByRole('button')).toHaveAccessibleName('Add item'); + for (const id of ['bare', 'span', 'label']) { + await expect(page.locator(`#${id}`).getByRole('button')).toHaveAccessibleName('Add item'); + } }); }); });