diff --git a/BREAKING.md b/BREAKING.md index 99ca0c9e610..973cc7211c2 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -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. [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. 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) +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 2f352e980ef..7d43cf97727 100644 --- a/core/src/components/button/button.scss +++ b/core/src/components/button/button.scss @@ -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])) { + @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 // -------------------------------------------------- diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index 009a5d5dc81..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. + * @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. * @@ -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 ad24f474098..a718637664e 100644 --- a/core/src/components/button/test/icon/button.e2e.ts +++ b/core/src/components/button/test/icon/button.e2e.ts @@ -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( + ` + + + Link + + Add item + 3 + 3 + + + `, + 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( + ` + Add item + Add item + Add item + `, + config + ); + + for (const id of ['bare', 'span', 'label']) { + await expect(page.locator(`#${id}`).getByRole('button')).toHaveAccessibleName('Add item'); + } + }); + }); +});