mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 04:09:12 +00:00
* [Tooltip] Specify inherited CSS properties on host, fixes #773 * Remove unused `--show-delay` and `--hide-delay`
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import { html } from 'lit';
|
|
import { customElement, property, query, state } from 'lit/decorators.js';
|
|
import { classMap } from 'lit/directives/class-map.js';
|
|
import { WaAfterHideEvent } from '../../events/after-hide.js';
|
|
import { WaAfterShowEvent } from '../../events/after-show.js';
|
|
import { WaHideEvent } from '../../events/hide.js';
|
|
import { WaShowEvent } from '../../events/show.js';
|
|
import { animateWithClass } from '../../internal/animate.js';
|
|
import { waitForEvent } from '../../internal/event.js';
|
|
import { uniqueId } from '../../internal/math.js';
|
|
import { watch } from '../../internal/watch.js';
|
|
import WebAwesomeElement from '../../internal/webawesome-element.js';
|
|
import WaPopup from '../popup/popup.js';
|
|
import styles from './tooltip.css';
|
|
|
|
/**
|
|
* @summary Tooltips display additional information based on a specific action.
|
|
* @documentation https://backers.webawesome.com/docs/components/tooltip
|
|
* @status stable
|
|
* @since 2.0
|
|
*
|
|
* @dependency wa-popup
|
|
*
|
|
* @slot - The tooltip's default slot where any content should live. Interactive content should be avoided.
|
|
*
|
|
* @event wa-show - Emitted when the tooltip begins to show.
|
|
* @event wa-after-show - Emitted after the tooltip has shown and all animations are complete.
|
|
* @event wa-hide - Emitted when the tooltip begins to hide.
|
|
* @event wa-after-hide - Emitted after the tooltip has hidden and all animations are complete.
|
|
*
|
|
* @csspart base - The component's base wrapper, an `<wa-popup>` element.
|
|
* @csspart base__popup - The popup's exported `popup` part. Use this to target the tooltip's popup container.
|
|
* @csspart base__arrow - The popup's exported `arrow` part. Use this to target the tooltip's arrow.
|
|
* @csspart body - The tooltip's body where its content is rendered.
|
|
*
|
|
* @cssproperty --background-color - The tooltip's background color.
|
|
* @cssproperty --border-radius - The radius of the tooltip's corners.
|
|
* @cssproperty --max-width - The maximum width of the tooltip before its content will wrap.
|
|
* @cssproperty --padding - The padding within the tooltip.
|
|
*/
|
|
@customElement('wa-tooltip')
|
|
export default class WaTooltip extends WebAwesomeElement {
|
|
static shadowStyle = styles;
|
|
static dependencies = { 'wa-popup': WaPopup };
|
|
|
|
private hoverTimeout: number;
|
|
private closeWatcher: CloseWatcher | null;
|
|
|
|
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
|
@query('.body') body: HTMLElement;
|
|
@query('wa-popup') popup: WaPopup;
|
|
|
|
/**
|
|
* The preferred placement of the tooltip. Note that the actual placement may vary as needed to keep the tooltip
|
|
* inside of the viewport.
|
|
*/
|
|
@property() placement:
|
|
| 'top'
|
|
| 'top-start'
|
|
| 'top-end'
|
|
| 'right'
|
|
| 'right-start'
|
|
| 'right-end'
|
|
| 'bottom'
|
|
| 'bottom-start'
|
|
| 'bottom-end'
|
|
| 'left'
|
|
| 'left-start'
|
|
| 'left-end' = 'top';
|
|
|
|
/** Disables the tooltip so it won't show when triggered. */
|
|
@property({ type: Boolean, reflect: true }) disabled = false;
|
|
|
|
/** The distance in pixels from which to offset the tooltip away from its target. */
|
|
@property({ type: Number }) distance = 8;
|
|
|
|
/** Indicates whether or not the tooltip is open. You can use this in lieu of the show/hide methods. */
|
|
@property({ type: Boolean, reflect: true }) open = false;
|
|
|
|
/** The distance in pixels from which to offset the tooltip along its target. */
|
|
@property({ type: Number }) skidding = 0;
|
|
|
|
/** The amount of time to wait before showing the tooltip when the user mouses in. */
|
|
@property({ attribute: 'show-delay', type: Number }) showDelay = 150;
|
|
|
|
/** The amount of time to wait before hiding the tooltip when the user mouses out.. */
|
|
@property({ attribute: 'hide-delay', type: Number }) hideDelay = 0;
|
|
|
|
/**
|
|
* Controls how the tooltip is activated. Possible options include `click`, `hover`, `focus`, and `manual`. Multiple
|
|
* options can be passed by separating them with a space. When manual is used, the tooltip must be activated
|
|
* programmatically.
|
|
*/
|
|
@property() trigger = 'hover focus';
|
|
|
|
/**
|
|
* Enable this option to prevent the tooltip from being clipped when the component is placed inside a container with
|
|
* `overflow: auto|hidden|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all,
|
|
* scenarios.
|
|
*/
|
|
@property({ type: Boolean }) hoist = false;
|
|
|
|
@property() for: string | null = null;
|
|
|
|
@state() anchor: null | Element = null;
|
|
|
|
private eventController = new AbortController();
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
|
|
// TODO: This is a hack that I need to revisit [Konnor]
|
|
if (this.open) {
|
|
this.open = false;
|
|
this.updateComplete.then(() => {
|
|
this.open = true;
|
|
});
|
|
}
|
|
|
|
// If the user doesn't give us an id, generate one.
|
|
if (!this.id) {
|
|
this.id = uniqueId('wa-tooltip-');
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
|
|
// Cleanup this event in case the tooltip is removed while open
|
|
this.closeWatcher?.destroy();
|
|
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
|
this.eventController.abort();
|
|
|
|
if (this.anchor) {
|
|
const label = this.anchor.getAttribute('aria-labelledby') || '';
|
|
this.anchor.setAttribute('aria-labelledby', label.replace(this.id, ''));
|
|
}
|
|
}
|
|
|
|
firstUpdated() {
|
|
this.body.hidden = !this.open;
|
|
|
|
// If the tooltip is visible on init, update its position
|
|
if (this.open) {
|
|
this.popup.active = true;
|
|
this.popup.reposition();
|
|
}
|
|
}
|
|
|
|
private handleBlur = () => {
|
|
if (this.hasTrigger('focus')) {
|
|
this.hide();
|
|
}
|
|
};
|
|
|
|
private handleClick = () => {
|
|
if (this.hasTrigger('click')) {
|
|
if (this.open) {
|
|
this.hide();
|
|
} else {
|
|
this.show();
|
|
}
|
|
}
|
|
};
|
|
|
|
private handleFocus = () => {
|
|
if (this.hasTrigger('focus')) {
|
|
this.show();
|
|
}
|
|
};
|
|
|
|
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
|
// Pressing escape when a tooltip is open should dismiss it
|
|
if (event.key === 'Escape') {
|
|
event.stopPropagation();
|
|
this.hide();
|
|
}
|
|
};
|
|
|
|
private handleMouseOver = () => {
|
|
if (this.hasTrigger('hover')) {
|
|
clearTimeout(this.hoverTimeout);
|
|
|
|
this.hoverTimeout = window.setTimeout(() => this.show(), this.showDelay);
|
|
}
|
|
};
|
|
|
|
private handleMouseOut = () => {
|
|
if (this.hasTrigger('hover')) {
|
|
clearTimeout(this.hoverTimeout);
|
|
this.hoverTimeout = window.setTimeout(() => this.hide(), this.hideDelay);
|
|
}
|
|
};
|
|
|
|
private hasTrigger(triggerType: string) {
|
|
const triggers = this.trigger.split(' ');
|
|
return triggers.includes(triggerType);
|
|
}
|
|
|
|
@watch('open', { waitUntilFirstUpdate: true })
|
|
async handleOpenChange() {
|
|
if (this.open) {
|
|
if (this.disabled) {
|
|
return;
|
|
}
|
|
|
|
// Show
|
|
const waShowEvent = new WaShowEvent();
|
|
this.dispatchEvent(waShowEvent);
|
|
if (waShowEvent.defaultPrevented) {
|
|
this.open = false;
|
|
return;
|
|
}
|
|
|
|
if ('CloseWatcher' in window) {
|
|
this.closeWatcher?.destroy();
|
|
this.closeWatcher = new CloseWatcher();
|
|
this.closeWatcher.onclose = () => {
|
|
this.hide();
|
|
};
|
|
} else {
|
|
document.addEventListener('keydown', this.handleDocumentKeyDown, { signal: this.eventController.signal });
|
|
}
|
|
|
|
this.body.hidden = false;
|
|
this.popup.active = true;
|
|
await animateWithClass(this.popup.popup, 'show-with-scale');
|
|
this.popup.reposition();
|
|
|
|
this.dispatchEvent(new WaAfterShowEvent());
|
|
} else {
|
|
// Hide
|
|
const waHideEvent = new WaHideEvent();
|
|
this.dispatchEvent(waHideEvent);
|
|
if (waHideEvent.defaultPrevented) {
|
|
this.open = false;
|
|
return;
|
|
}
|
|
|
|
this.closeWatcher?.destroy();
|
|
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
|
|
|
await animateWithClass(this.popup.popup, 'hide-with-scale');
|
|
this.popup.active = false;
|
|
this.body.hidden = true;
|
|
|
|
this.dispatchEvent(new WaAfterHideEvent());
|
|
}
|
|
}
|
|
|
|
@watch('for')
|
|
handleForChange() {
|
|
const rootNode = this.getRootNode() as Document | ShadowRoot | null;
|
|
|
|
if (!rootNode) {
|
|
return;
|
|
}
|
|
|
|
const newAnchor = this.for ? rootNode.querySelector(`#${this.for}`) : null;
|
|
const oldAnchor = this.anchor;
|
|
|
|
if (newAnchor === oldAnchor) {
|
|
return;
|
|
}
|
|
|
|
const { signal } = this.eventController;
|
|
|
|
// "\\b" is a space boundary, used for making sure we dont add the tooltip to aria-labelledby twice.
|
|
const labelRegex = new RegExp(`\\b${this.id}\\b`);
|
|
|
|
if (newAnchor) {
|
|
/**
|
|
* We use `aria-labelledby` because it seems to have the most consistent screenreader experience.
|
|
* Particularly for our "special" focusable elements like `<wa-button>`, `<wa-input>` etc.
|
|
* aria-describedby usually in some screenreaders is required to be on the actually focusable element,
|
|
* whereas with `aria-labelledby` it'll still read on first focus. The APG does and WAI-ARIA does recommend aria-describedby
|
|
* so perhaps once we have cross-root aria, we can revisit this decision.
|
|
*/
|
|
const currentLabel = newAnchor.getAttribute('aria-labelledby') || '';
|
|
if (!currentLabel.match(labelRegex)) {
|
|
newAnchor.setAttribute('aria-labelledby', currentLabel + ' ' + this.id);
|
|
}
|
|
|
|
newAnchor.addEventListener('blur', this.handleBlur, { capture: true, signal });
|
|
newAnchor.addEventListener('focus', this.handleFocus, { capture: true, signal });
|
|
newAnchor.addEventListener('click', this.handleClick, { signal });
|
|
newAnchor.addEventListener('mouseover', this.handleMouseOver, { signal });
|
|
newAnchor.addEventListener('mouseout', this.handleMouseOut, { signal });
|
|
}
|
|
|
|
if (oldAnchor) {
|
|
const label = oldAnchor.getAttribute('aria-labelledby') || '';
|
|
oldAnchor.setAttribute('aria-labelledby', label.replace(labelRegex, ''));
|
|
oldAnchor.removeEventListener('blur', this.handleBlur, { capture: true });
|
|
oldAnchor.removeEventListener('focus', this.handleFocus, { capture: true });
|
|
oldAnchor.removeEventListener('click', this.handleClick);
|
|
oldAnchor.removeEventListener('mouseover', this.handleMouseOver);
|
|
oldAnchor.removeEventListener('mouseout', this.handleMouseOut);
|
|
}
|
|
|
|
this.anchor = newAnchor;
|
|
}
|
|
|
|
@watch(['distance', 'hoist', 'placement', 'skidding'])
|
|
async handleOptionsChange() {
|
|
if (this.hasUpdated) {
|
|
await this.updateComplete;
|
|
this.popup.reposition();
|
|
}
|
|
}
|
|
|
|
@watch('disabled')
|
|
handleDisabledChange() {
|
|
if (this.disabled && this.open) {
|
|
this.hide();
|
|
}
|
|
}
|
|
|
|
/** Shows the tooltip. */
|
|
async show() {
|
|
if (this.open) {
|
|
return undefined;
|
|
}
|
|
|
|
this.open = true;
|
|
return waitForEvent(this, 'wa-after-show');
|
|
}
|
|
|
|
/** Hides the tooltip */
|
|
async hide() {
|
|
if (!this.open) {
|
|
return undefined;
|
|
}
|
|
|
|
this.open = false;
|
|
return waitForEvent(this, 'wa-after-hide');
|
|
}
|
|
|
|
render() {
|
|
return html`
|
|
<wa-popup
|
|
part="base"
|
|
exportparts="
|
|
popup:base__popup,
|
|
arrow:base__arrow
|
|
"
|
|
class=${classMap({
|
|
tooltip: true,
|
|
'tooltip--open': this.open,
|
|
})}
|
|
placement=${this.placement}
|
|
distance=${this.distance}
|
|
skidding=${this.skidding}
|
|
strategy=${this.hoist ? 'fixed' : 'absolute'}
|
|
flip
|
|
shift
|
|
arrow
|
|
hover-bridge
|
|
.anchor=${this.anchor}
|
|
>
|
|
<div part="body" class="body">
|
|
<slot></slot>
|
|
</div>
|
|
</wa-popup>
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'wa-tooltip': WaTooltip;
|
|
}
|
|
}
|