diff --git a/packages/badge/package.json b/packages/badge/package.json index a6d988d039b..c1215790ab0 100644 --- a/packages/badge/package.json +++ b/packages/badge/package.json @@ -36,6 +36,7 @@ "dependencies": { "@vaadin/a11y-base": "25.2.0-alpha12", "@vaadin/component-base": "25.2.0-alpha12", + "@vaadin/tooltip": "25.2.0-alpha12", "@vaadin/vaadin-themable-mixin": "25.2.0-alpha12", "lit": "^3.0.0" }, diff --git a/packages/badge/src/vaadin-badge.d.ts b/packages/badge/src/vaadin-badge.d.ts index d3462f32e44..463933afbdd 100644 --- a/packages/badge/src/vaadin-badge.d.ts +++ b/packages/badge/src/vaadin-badge.d.ts @@ -19,6 +19,7 @@ import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mix * ---------|------------- * (none) | Default slot for the badge text content * `icon` | Slot for an icon to place before the text + * `tooltip` | Slot for a tooltip * * ### Styling * @@ -37,6 +38,7 @@ import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mix * `has-icon` | Set when the badge has content in the icon slot * `has-content` | Set when the badge has content in the default slot * `has-number` | Set when the badge has a number value + * `has-tooltip` | Set when the badge has a slotted tooltip * * The following custom CSS properties are available for styling: * @@ -57,6 +59,11 @@ import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mix * See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation. */ declare class Badge extends ElementMixin(ThemableMixin(HTMLElement)) { + /** + * When enabled, hides the content visually and shows it in a tooltip. + */ + autoTooltip: boolean; + /** * The number to display in the badge. */ diff --git a/packages/badge/src/vaadin-badge.js b/packages/badge/src/vaadin-badge.js index ee174f1c616..cd812d325e3 100644 --- a/packages/badge/src/vaadin-badge.js +++ b/packages/badge/src/vaadin-badge.js @@ -3,6 +3,7 @@ * Copyright (c) 2026 - 2026 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ +import '@vaadin/tooltip/src/vaadin-tooltip.js'; import { html, LitElement } from 'lit'; import { classMap } from 'lit/directives/class-map.js'; import { screenReaderOnly } from '@vaadin/a11y-base/src/styles/sr-only-styles.js'; @@ -11,6 +12,7 @@ import { isEmptyTextNode } from '@vaadin/component-base/src/dom-utils.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; import { SlotObserver } from '@vaadin/component-base/src/slot-observer.js'; +import { TooltipController } from '@vaadin/component-base/src/tooltip-controller.js'; import { LumoInjectionMixin } from '@vaadin/vaadin-themable-mixin/lumo-injection-mixin.js'; import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; import { badgeStyles } from './styles/vaadin-badge-base-styles.js'; @@ -28,6 +30,7 @@ import { badgeStyles } from './styles/vaadin-badge-base-styles.js'; * ---------|------------- * (none) | Default slot for the badge text content * `icon` | Slot for an icon to place before the text + * `tooltip` | Slot for a tooltip * * ### Styling * @@ -46,6 +49,7 @@ import { badgeStyles } from './styles/vaadin-badge-base-styles.js'; * `has-icon` | Set when the badge has content in the icon slot * `has-content` | Set when the badge has content in the default slot * `has-number` | Set when the badge has a number value + * `has-tooltip` | Set when the badge has a slotted tooltip * * The following custom CSS properties are available for styling: * @@ -93,6 +97,16 @@ class Badge extends ElementMixin(ThemableMixin(PolylitMixin(LumoInjectionMixin(L number: { type: Number, }, + + /** + * When enabled, hides the content visually and shows it in a tooltip. + */ + autoTooltip: { + type: Boolean, + value: false, + reflectToAttribute: true, + sync: true, + }, }; } @@ -108,9 +122,10 @@ class Badge extends ElementMixin(ThemableMixin(PolylitMixin(LumoInjectionMixin(L
${this.number}
-
- +
+
+ `; } @@ -123,6 +138,24 @@ class Badge extends ElementMixin(ThemableMixin(PolylitMixin(LumoInjectionMixin(L } } + /** @protected */ + updated(props) { + super.updated(props); + + if (props.has('autoTooltip')) { + this.__updateAutoTooltip(); + } + } + + /** @protected */ + ready() { + super.ready(); + + this._tooltipController = new TooltipController(this); + this.addController(this._tooltipController); + this.__updateAutoTooltip(); + } + /** @protected */ firstUpdated() { super.firstUpdated(); @@ -137,6 +170,45 @@ class Badge extends ElementMixin(ThemableMixin(PolylitMixin(LumoInjectionMixin(L this.toggleAttribute('has-icon', currentNodes.length > 0); }); } + + /** @private */ + __getContentText() { + const slot = this.shadowRoot.querySelector('slot:not([name])'); + return slot + ? slot + .assignedNodes({ flatten: true }) + .map((node) => node.textContent) + .join('') + .trim() + : ''; + } + + /** @private */ + __hasCustomTooltip() { + return Array.from(this.children).some((node) => node !== this.__autoTooltip && node.slot === 'tooltip'); + } + + /** @private */ + __updateAutoTooltip() { + const text = this.__getContentText(); + + if (!this.autoTooltip || !text || this.__hasCustomTooltip()) { + this.__autoTooltip?.remove(); + return; + } + + if (!this.__autoTooltip) { + this.__autoTooltip = document.createElement('vaadin-tooltip'); + this.__autoTooltip.setAttribute('slot', 'tooltip'); + this.__autoTooltip.ariaTarget = null; + } + + this.__autoTooltip.setAttribute('text', text); + + if (!this.__autoTooltip.isConnected) { + this.appendChild(this.__autoTooltip); + } + } } defineCustomElement(Badge); diff --git a/packages/badge/test/badge.test.ts b/packages/badge/test/badge.test.ts index 21e3f4f84ec..20a59b464f4 100644 --- a/packages/badge/test/badge.test.ts +++ b/packages/badge/test/badge.test.ts @@ -116,4 +116,33 @@ describe('vaadin-badge', () => { expect(badge.hasAttribute('has-icon')).to.be.false; }); }); + + describe('auto-tooltip', () => { + beforeEach(async () => { + badge = fixtureSync('New'); + await nextRender(); + }); + + it('should hide the content visually', () => { + const content = badge.shadowRoot!.querySelector('[part="content"]')!; + + expect(content.classList.contains('sr-only')).to.be.true; + }); + + it('should add a tooltip with content text', () => { + const tooltip = badge.querySelector('vaadin-tooltip[slot="tooltip"]')!; + + expect(tooltip).to.be.ok; + expect(tooltip.getAttribute('text')).to.equal('New'); + }); + + it('should not set aria-describedby', async () => { + const tooltip = badge.querySelector('vaadin-tooltip[slot="tooltip"]')! as any; + await nextUpdate(tooltip); + + expect(badge.hasAttribute('auto-tooltip')).to.be.true; + expect(tooltip.ariaTarget).to.equal(null); + expect(badge.hasAttribute('aria-describedby')).to.be.false; + }); + }); }); diff --git a/packages/badge/test/dom/__snapshots__/badge.test.snap.js b/packages/badge/test/dom/__snapshots__/badge.test.snap.js index 4c501ce9fdb..54c29388318 100644 --- a/packages/badge/test/dom/__snapshots__/badge.test.snap.js +++ b/packages/badge/test/dom/__snapshots__/badge.test.snap.js @@ -31,6 +31,8 @@ snapshots["vaadin-badge shadow default"] =
+ + `; /* end snapshot vaadin-badge shadow default */ @@ -46,6 +48,8 @@ snapshots["vaadin-badge shadow number"] = + + `; /* end snapshot vaadin-badge shadow number */ @@ -69,6 +73,8 @@ snapshots["vaadin-badge shadow dot"] = + + `; /* end snapshot vaadin-badge shadow dot */ @@ -89,6 +95,8 @@ snapshots["vaadin-badge shadow icon-only"] = + + `; /* end snapshot vaadin-badge shadow icon-only */ @@ -109,6 +117,8 @@ snapshots["vaadin-badge shadow number-only"] = + + `; /* end snapshot vaadin-badge shadow number-only */ diff --git a/packages/button/package.json b/packages/button/package.json index 9cf7770b79e..487a9c7098d 100644 --- a/packages/button/package.json +++ b/packages/button/package.json @@ -37,6 +37,7 @@ "@open-wc/dedupe-mixin": "^1.3.0", "@vaadin/a11y-base": "25.2.0-alpha12", "@vaadin/component-base": "25.2.0-alpha12", + "@vaadin/tooltip": "25.2.0-alpha12", "@vaadin/vaadin-themable-mixin": "25.2.0-alpha12", "lit": "^3.0.0" }, diff --git a/packages/button/src/vaadin-button.d.ts b/packages/button/src/vaadin-button.d.ts index 530c9bbbd43..ad8136b1233 100644 --- a/packages/button/src/vaadin-button.d.ts +++ b/packages/button/src/vaadin-button.d.ts @@ -54,6 +54,11 @@ import { ButtonMixin } from './vaadin-button-mixin.js'; * See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation. */ declare class Button extends ButtonMixin(ElementMixin(ThemableMixin(HTMLElement))) { + /** + * When enabled, hides the label visually and shows it in a tooltip. + */ + autoTooltip: boolean; + /** * When disabled, the button is rendered as "dimmed". * diff --git a/packages/button/src/vaadin-button.js b/packages/button/src/vaadin-button.js index d50336e7545..23544e6637c 100644 --- a/packages/button/src/vaadin-button.js +++ b/packages/button/src/vaadin-button.js @@ -3,7 +3,10 @@ * Copyright (c) 2017 - 2026 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ +import '@vaadin/tooltip/src/vaadin-tooltip.js'; import { html, LitElement } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; +import { screenReaderOnly } from '@vaadin/a11y-base/src/styles/sr-only-styles.js'; import { defineCustomElement } from '@vaadin/component-base/src/define.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; @@ -68,7 +71,7 @@ class Button extends ButtonMixin(ElementMixin(ThemableMixin(PolylitMixin(LumoInj } static get styles() { - return buttonStyles; + return [buttonStyles, screenReaderOnly]; } static get properties() { @@ -95,6 +98,16 @@ class Button extends ButtonMixin(ElementMixin(ThemableMixin(PolylitMixin(LumoInj reflectToAttribute: true, sync: true, }, + + /** + * When enabled, hides the label visually and shows it in a tooltip. + */ + autoTooltip: { + type: Boolean, + value: false, + reflectToAttribute: true, + sync: true, + }, }; } @@ -105,30 +118,79 @@ class Button extends ButtonMixin(ElementMixin(ThemableMixin(PolylitMixin(LumoInj - - + + - + `; } + /** @protected */ + updated(props) { + super.updated(props); + + if (props.has('autoTooltip')) { + this.__updateAutoTooltip(); + } + } + /** @protected */ ready() { super.ready(); this._tooltipController = new TooltipController(this); this.addController(this._tooltipController); + this.__updateAutoTooltip(); } /** @override */ __shouldAllowFocusWhenDisabled() { return window.Vaadin.featureFlags.accessibleDisabledButtons; } + + /** @private */ + __getLabelText() { + const slot = this.shadowRoot.querySelector('slot:not([name])'); + return slot + ? slot + .assignedNodes({ flatten: true }) + .map((node) => node.textContent) + .join('') + .trim() + : ''; + } + + /** @private */ + __hasCustomTooltip() { + return Array.from(this.children).some((node) => node !== this.__autoTooltip && node.slot === 'tooltip'); + } + + /** @private */ + __updateAutoTooltip() { + const text = this.__getLabelText(); + + if (!this.autoTooltip || !text || this.__hasCustomTooltip()) { + this.__autoTooltip?.remove(); + return; + } + + if (!this.__autoTooltip) { + this.__autoTooltip = document.createElement('vaadin-tooltip'); + this.__autoTooltip.setAttribute('slot', 'tooltip'); + this.__autoTooltip.ariaTarget = null; + } + + this.__autoTooltip.setAttribute('text', text); + + if (!this.__autoTooltip.isConnected) { + this.appendChild(this.__autoTooltip); + } + } } defineCustomElement(Button); diff --git a/packages/button/test/button.test.ts b/packages/button/test/button.test.ts index 58ce2f0e747..3e90b3f5587 100644 --- a/packages/button/test/button.test.ts +++ b/packages/button/test/button.test.ts @@ -206,4 +206,33 @@ describe('vaadin-button', () => { expect(document.activeElement).to.equal(lastGlobalFocusable); }); }); + + describe('auto-tooltip', () => { + beforeEach(async () => { + button = fixtureSync('Press me'); + await nextRender(); + }); + + it('should hide the label visually', () => { + const label = button.shadowRoot!.querySelector('[part="label"]')!; + + expect(label.classList.contains('sr-only')).to.be.true; + }); + + it('should add a tooltip with label text', () => { + const tooltip = button.querySelector('vaadin-tooltip[slot="tooltip"]')!; + + expect(tooltip).to.be.ok; + expect(tooltip.getAttribute('text')).to.equal('Press me'); + }); + + it('should not set aria-describedby', async () => { + const tooltip = button.querySelector('vaadin-tooltip[slot="tooltip"]')! as any; + await nextUpdate(tooltip); + + expect(button.hasAttribute('auto-tooltip')).to.be.true; + expect(tooltip.ariaTarget).to.equal(null); + expect(button.hasAttribute('aria-describedby')).to.be.false; + }); + }); });