Skip to content
Closed
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
38 changes: 38 additions & 0 deletions docs/components/tooltip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!-- catalog-only-start --><!-- ---
name: Tooltip
dirname: tooltip
-----><!-- catalog-only-end -->

# Tooltip

`<md-tooltip>` is an experimental Material 3 tooltip in `labs`. It provides
supplementary text for a trigger on pointer hover or keyboard focus.

> This component is experimental. Its API and visual styling may change
> without a major version bump.

## Usage

Wrap one trigger element and provide its text with the `text` attribute:

```html
<md-tooltip text="Save your changes">
<md-filled-button>Save</md-filled-button>
</md-tooltip>
```

The trigger receives `aria-describedby` automatically while the tooltip is
connected. Tooltips open on focus immediately and on pointer hover after a
short delay. Pressing Escape hides the tooltip without moving focus.

## API

| Attribute/property | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` | `''` | Tooltip text. |
| `placement` | `'top' \| 'bottom' \| 'start' \| 'end'` | `'top'` | Position relative to the trigger. |
| `delay` | `number` | `500` | Pointer-hover delay in milliseconds. |
| `open` | `boolean` | `false` | Whether the tooltip is currently visible. |

The `show()` and `hide()` methods can be used for imperative control. Rich
content can be provided with an element using `slot="content"`.
6 changes: 6 additions & 0 deletions labs/tooltip/_tooltip.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0
//

@forward './internal/tooltip' show theme;
26 changes: 26 additions & 0 deletions labs/tooltip/demo/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import './material-collection.js';
import './index.js';

import {
KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';

import {stories, StoryKnobs} from './stories.js';

const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
'Tooltip',
[],
);

collection.addStories(...materialInitsToStoryInits(stories));

setUpDemo(collection);
9 changes: 9 additions & 0 deletions labs/tooltip/demo/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "/assets/stories/base.json",
"files": {
"demo.ts": {
"hidden": true
},
"stories.ts": {}
}
}
42 changes: 42 additions & 0 deletions labs/tooltip/demo/stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import '@material/web/button/filled-button.js';
import '@material/web/labs/tooltip/tooltip.js';

import {MaterialStoryInit} from './material-collection.js';
import {css, html} from 'lit';

export interface StoryKnobs {}

const styles = css`
.examples {
align-items: center;
display: flex;
gap: 32px;
justify-content: center;
min-height: 160px;
}
`;

const basic: MaterialStoryInit<StoryKnobs> = {
name: 'Tooltip',
styles,
render() {
return html`
<div class="examples">
<md-tooltip text="Save your changes">
<md-filled-button>Save</md-filled-button>
</md-tooltip>
<md-tooltip text="Additional information" placement="bottom">
<button aria-label="More information">Info</button>
</md-tooltip>
</div>
`;
},
};

export const stories = [basic];
27 changes: 27 additions & 0 deletions labs/tooltip/harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {MdTooltip} from './tooltip.js';

export class TooltipHarness {
constructor(readonly element: MdTooltip) {}

get popup() {
return this.element.shadowRoot!.querySelector<HTMLElement>(
'[role="tooltip"]',
)!;
}

async show() {
this.element.show();
await this.element.updateComplete;
}

async hide() {
this.element.hide();
await this.element.updateComplete;
}
}
62 changes: 62 additions & 0 deletions labs/tooltip/internal/_tooltip.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0
//

@mixin theme($tokens: ()) {
@each $token, $value in $tokens {
@if $value {
--md-tooltip-#{$token}: #{$value};
}
}
}

@mixin styles() {
:host {
display: inline-block;
position: relative;
}

[role='tooltip'] {
background: var(--md-sys-color-inverse-surface, #322f35);
border-radius: var(--md-sys-shape-corner-extra-small, 4px);
box-sizing: border-box;
color: var(--md-sys-color-inverse-on-surface, #f5eff7);
font: var(--md-sys-typescale-body-small, 0.75rem / 1rem sans-serif);
max-width: min(320px, calc(100vw - 32px));
padding: 8px;
pointer-events: none;
position: absolute;
white-space: normal;
width: max-content;
z-index: 1;
}

[role='tooltip'][hidden] {
display: none;
}

:host([placement='top']) [role='tooltip'] {
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
}

:host([placement='bottom']) [role='tooltip'] {
left: 50%;
top: calc(100% + 8px);
transform: translateX(-50%);
}

:host([placement='start']) [role='tooltip'] {
right: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
}

:host([placement='end']) [role='tooltip'] {
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
}
}
8 changes: 8 additions & 0 deletions labs/tooltip/internal/tooltip-styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0
//

@use './tooltip';

@include tooltip.styles;
159 changes: 159 additions & 0 deletions labs/tooltip/internal/tooltip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {html, LitElement, nothing} from 'lit';
import {property, queryAssignedElements, state} from 'lit/decorators.js';

const tooltipId = 'md-tooltip';

/**
* An accessibility-first tooltip for a single interactive or descriptive
* element.
*/
export class Tooltip extends LitElement {
@property() text = '';

@property({reflect: true}) placement:
| 'top'
| 'bottom'
| 'start'
| 'end' = 'top';

@property({type: Number}) delay = 500;

@property({type: Boolean, reflect: true}) open = false;

@state() private hasTrigger = false;

@queryAssignedElements({flatten: true})
private readonly assignedElements!: HTMLElement[];

private showTimer?: number;
private hideTimer?: number;
private readonly tooltipElementId = `${tooltipId}-${Math.random()
.toString(36)
.slice(2)}`;

protected override render() {
return html`
<slot
@slotchange=${this.handleSlotChange}></slot>
<div
id=${this.tooltipElementId}
role="tooltip"
aria-hidden=${this.open ? 'false' : 'true'}
?hidden=${!this.open}>
${this.text || nothing}<slot name="content"></slot>
</div>
`;
}

override connectedCallback() {
super.connectedCallback();
this.addEventListener('pointerenter', this.handlePointerEnter);
this.addEventListener('pointerleave', this.handlePointerLeave);
this.addEventListener('focusin', this.handleFocusIn);
this.addEventListener('focusout', this.handleFocusOut);
this.addEventListener('keydown', this.handleKeydown);
}

override disconnectedCallback() {
this.clearTimers();
this.removeDescribedBy();
this.removeEventListener('pointerenter', this.handlePointerEnter);
this.removeEventListener('pointerleave', this.handlePointerLeave);
this.removeEventListener('focusin', this.handleFocusIn);
this.removeEventListener('focusout', this.handleFocusOut);
this.removeEventListener('keydown', this.handleKeydown);
super.disconnectedCallback();
}

protected override updated() {
this.updateDescribedBy();
}

/** Shows the tooltip immediately. */
show() {
this.clearTimers();
if (this.hasTrigger && (this.text || this.querySelector('[slot="content"]'))) {
this.open = true;
}
}

/** Hides the tooltip immediately. */
hide() {
this.clearTimers();
this.open = false;
}

private handleSlotChange = () => {
this.hasTrigger = this.assignedElements.length > 0;
this.updateDescribedBy();
};

private handlePointerEnter = () => {
this.clearTimers();
this.showTimer = window.setTimeout(() => this.show(), this.delay);
};

private handlePointerLeave = () => {
this.clearTimers();
this.hideTimer = window.setTimeout(() => this.hide(), 100);
};

private handleFocusIn = () => {
this.clearTimers();
this.show();
};

private handleFocusOut = (event: FocusEvent) => {
if (event.relatedTarget instanceof Node && this.contains(event.relatedTarget)) {
return;
}
this.hide();
};

private handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
this.hide();
}
};

private clearTimers() {
if (this.showTimer !== undefined) {
window.clearTimeout(this.showTimer);
this.showTimer = undefined;
}
if (this.hideTimer !== undefined) {
window.clearTimeout(this.hideTimer);
this.hideTimer = undefined;
}
}

private updateDescribedBy() {
const trigger = this.assignedElements?.[0];
if (!trigger) return;
const describedBy = new Set(
(trigger.getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean),
);
describedBy.add(this.tooltipElementId);
trigger.setAttribute('aria-describedby', [...describedBy].join(' '));
}

private removeDescribedBy() {
for (const trigger of this.assignedElements ?? []) {
const describedBy = (trigger.getAttribute('aria-describedby') ?? '')
.split(' ')
.filter((value) => value && value !== this.tooltipElementId);
if (describedBy.length) {
trigger.setAttribute('aria-describedby', describedBy.join(' '));
} else {
trigger.removeAttribute('aria-describedby');
}
}
}
}
Loading