import {
closestComposedNode,
getMediaController,
getStringAttr,
isElementVisible,
setStringAttr,
} from './utils/element-utils.js';
import { globalThis, document } from './utils/server-safe-globals.js';
export const Attributes = {
PLACEMENT: 'placement',
BOUNDS: 'bounds',
};
export type TooltipPlacement = 'top' | 'right' | 'bottom' | 'left' | 'none';
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = /*html*/ `
`;
/**
* @extends {HTMLElement}
*
* @attr {('top'|'right'|'bottom'|'left'|'none')} placement - The placement of the tooltip, defaults to "top"
* @attr {string} bounds - ID for the containing element (one of it's parents) that should constrain the tooltips horizontal position.
*
* @cssproperty --media-primary-color - Default color of text.
* @cssproperty --media-secondary-color - Default color of tooltip background.
* @cssproperty --media-text-color - `color` of tooltip text.
*
* @cssproperty --media-font - `font` shorthand property.
* @cssproperty --media-font-weight - `font-weight` property.
* @cssproperty --media-font-family - `font-family` property.
* @cssproperty --media-font-size - `font-size` property.
* @cssproperty --media-text-content-height - `line-height` of button text.
*
* @cssproperty --media-tooltip-border - 'border' of tooltip
* @cssproperty --media-tooltip-background-color - Background color of tooltip and arrow, unless individually overidden
* @cssproperty --media-tooltip-background - `background` of tooltip, ignoring the arrow
* @cssproperty --media-tooltip-display - `display` of tooltip
* @cssproperty --media-tooltip-z-index - `z-index` of tooltip
* @cssproperty --media-tooltip-padding - `padding` of tooltip
* @cssproperty --media-tooltip-border-radius - `border-radius` of tooltip
* @cssproperty --media-tooltip-filter - `filter` property of tooltip, for drop-shadow
* @cssproperty --media-tooltip-white-space - `white-space` property of tooltip
* @cssproperty --media-tooltip-arrow-display - `display` property of tooltip arrow
* @cssproperty --media-tooltip-arrow-width - Arrow width
* @cssproperty --media-tooltip-arrow-height - Arrow height
* @cssproperty --media-tooltip-arrow-color - Arrow color
*/
class MediaTooltip extends globalThis.HTMLElement {
static get observedAttributes(): string[] {
return [Attributes.PLACEMENT, Attributes.BOUNDS];
}
arrowEl: HTMLElement;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
this.arrowEl = this.shadowRoot.querySelector('#arrow');
// Check if the placement prop has been set before the element was
// defined / upgraded. Without this, placement might be permanently overriden
// on the target element.
// see: https://nolanlawson.com/2021/08/03/handling-properties-in-custom-element-upgrades/
if (Object.prototype.hasOwnProperty.call(this, 'placement')) {
const placement = this.placement;
delete this.placement;
this.placement = placement;
}
}
// Adjusts tooltip position relative to the closest specified container
// such that it doesn't spill out of the left or right sides. Only applies
// to 'top' and 'bottom' placed tooltips.
updateXOffset = () => {
// If the tooltip is hidden don't offset the tooltip because it could be
// positioned offscreen causing scrollbars to appear.
if (!isElementVisible(this, { checkOpacity: false, checkVisibilityCSS: false })) return;
const placement = this.placement;
// we don't offset against tooltips coming out of left and right sides
if (placement === 'left' || placement === 'right') {
// could have been offset before switching to a new position
this.style.removeProperty('--media-tooltip-offset-x');
return;
}
// We need to calculate the difference (diff) between the left edge of the
// tooltip compared to the left edge of the container element, to see if it
// bleeds out (and the same for the right edges).
// If they do, then we apply the diff as an offset to get it back within bounds
// + any extra margin specified to create some buffer space, so it looks better.
// e.g. it's 20px out of bounds, we nudge it 20px back in + margin
const tooltipStyle = getComputedStyle(this);
const containingEl =
closestComposedNode(this, '#' + this.bounds) ?? getMediaController(this);
if (!containingEl) return;
const { x: containerX, width: containerWidth } =
containingEl.getBoundingClientRect();
const { x: tooltipX, width: tooltipWidth } = this.getBoundingClientRect();
const tooltipRight = tooltipX + tooltipWidth;
const containerRight = containerX + containerWidth;
const offsetXVal = tooltipStyle.getPropertyValue(
'--media-tooltip-offset-x'
);
const currOffsetX = offsetXVal
? parseFloat(offsetXVal.replace('px', ''))
: 0;
const marginVal = tooltipStyle.getPropertyValue(
'--media-tooltip-container-margin'
);
const currMargin = marginVal ? parseFloat(marginVal.replace('px', '')) : 0;
// We might have already offset the tooltip previously so we remove it's
// current offset from our calculations, because we need to know if it goes
// outside the boundary if we weren't already offsetting it
// We also add on any additional container margin specified. Depending on
// if we're adjusting the element leftwards or rightwards, we need either a
// positive or negative offset
const leftDiff = tooltipX - containerX + currOffsetX - currMargin;
const rightDiff = tooltipRight - containerRight + currOffsetX + currMargin;
// out of left bounds
if (leftDiff < 0) {
this.style.setProperty('--media-tooltip-offset-x', `${leftDiff}px`);
return;
}
// out of right bounds
if (rightDiff > 0) {
this.style.setProperty('--media-tooltip-offset-x', `${rightDiff}px`);
return;
}
// no spilling out
this.style.removeProperty('--media-tooltip-offset-x');
};
/**
* Get or set tooltip placement
*/
get placement(): TooltipPlacement | undefined {
return getStringAttr(this, Attributes.PLACEMENT);
}
set placement(value: TooltipPlacement | undefined) {
setStringAttr(this, Attributes.PLACEMENT, value);
}
/**
* Get or set tooltip container ID selector that will constrain the tooltips
* horizontal position.
*/
get bounds(): string | undefined {
return getStringAttr(this, Attributes.BOUNDS);
}
set bounds(value: string | undefined) {
setStringAttr(this, Attributes.BOUNDS, value);
}
}
if (!globalThis.customElements.get('media-tooltip')) {
globalThis.customElements.define('media-tooltip', MediaTooltip);
}
export default MediaTooltip;