blob: 90cce06a6d4caab1ef37481d966ab2d304d4a2d1 [file] [log] [blame] [edit]
// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {createPopper, Instance, OptionsGeneric} from '@popperjs/core';
import m from 'mithril';
import {MountOptions, Portal, PortalAttrs} from './portal';
import {classNames} from '../base/classnames';
import {findRef, toHTMLElement} from '../base/dom_utils';
import {assertExists} from '../base/logging';
import {PopupPosition} from './popup';
import {ExtendedModifiers} from './popper_utils';
export interface TooltipAttrs {
// Which side of the trigger to place to tooltip.
// Defaults to "Auto"
position?: PopupPosition;
// The element used to open and close the tooltip, and to which the tooltip
// will be anchored. Beware this element will have its `onmouseenter`,
// `onmouseleave`, `ref` attributes overwritten.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
trigger: m.Vnode<any, any>;
// Space delimited class names applied to the tooltip div.
className?: string;
// Whether to show a little arrow pointing to our trigger element.
// Defaults to true.
showArrow?: boolean;
// Called when the tooltip mounts, passing the tooltip's dom element.
onTooltipMount?: (dom: HTMLElement) => void;
// Called when the tooltip unmounts, padding the tooltip's dom element.
onTooltipUnMount?: (dom: HTMLElement) => void;
// Distance in px between the tooltip and its trigger. Default = 0.
offset?: number;
// Cross-axial tooltip offset in px. Defaults to 0.
// When position is *-end or *-start, this setting specifies where start and
// end is as an offset from the edge of the tooltip.
// Positive values move the positioning away from the edge towards the center
// of the tooltip.
// If position is not *-end or *-start, this setting has no effect.
edgeOffset?: number;
// If true, the tooltip will not have a maximum width and will instead fit its
// content. This is useful for tooltips that have a lot of buttons or other
// content that should not be constrained by a maximum width.
// Defaults to false.
fitContent?: boolean;
}
// A tooltip is a portal whose position is dynamically updated so that it floats
// next to a trigger element. It is also styled with a nice backdrop, and
// a little arrow pointing at the trigger element.
// Useful for displaying things like tooltips.
export class Tooltip implements m.ClassComponent<TooltipAttrs> {
private isOpen: boolean = false;
private triggerElement?: Element;
private tooltipElement?: HTMLElement;
private popper?: Instance;
private static readonly TRIGGER_REF = 'trigger';
private static readonly TOOLTIP_REF = 'tooltip';
view({attrs, children}: m.CVnode<TooltipAttrs>): m.Children {
const {trigger} = attrs;
return [
this.renderTrigger(trigger),
this.isOpen && this.renderToolip(attrs, children),
];
}
private renderTrigger(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
trigger: m.Vnode<any, any>,
): m.Children {
trigger.attrs = {
...trigger.attrs,
ref: Tooltip.TRIGGER_REF,
onmouseenter: () => {
this.isOpen = true;
},
onmouseleave: () => {
this.isOpen = false;
},
};
return trigger;
}
private renderToolip(attrs: TooltipAttrs, children: m.Children): m.Children {
const {
className,
showArrow = true,
onTooltipMount = () => {},
onTooltipUnMount = () => {},
fitContent,
} = attrs;
const portalAttrs: PortalAttrs = {
className: 'pf-tooltip-portal',
onBeforeContentMount: (dom: Element): MountOptions => {
// Check to see if dom is a descendant of a popup or modal
// If so, get the popup's "container" and put it in there instead
// This handles the case where popups are placed inside the other popups
// we nest outselves in their containers instead of document body which
// means we become part of their hitbox for mouse events.
const closestPopup = dom.closest(`[ref=${Tooltip.TOOLTIP_REF}]`);
if (closestPopup) {
return {container: closestPopup};
}
const closestContainer = dom.closest('.pf-overlay-container');
if (closestContainer) {
return {container: closestContainer};
}
return {container: undefined};
},
onContentMount: (dom: HTMLElement) => {
const popupElement = toHTMLElement(
assertExists(findRef(dom, Tooltip.TOOLTIP_REF)),
);
this.tooltipElement = popupElement;
this.createOrUpdatePopper(attrs);
onTooltipMount(popupElement);
},
onContentUpdate: () => {
this.popper?.update();
},
onContentUnmount: () => {
if (this.tooltipElement) {
onTooltipUnMount(this.tooltipElement);
}
this.popper?.destroy();
this.popper = undefined;
this.tooltipElement = undefined;
},
};
return m(
Portal,
portalAttrs,
m(
'.pf-popup', // Re-use popup styles
{
class: classNames(className, fitContent && 'pf-popup--fit-content'),
ref: Tooltip.TOOLTIP_REF,
},
showArrow && m('.pf-popup-arrow[data-popper-arrow]'),
m('.pf-popup-content', children),
),
);
}
oncreate({dom}: m.VnodeDOM<TooltipAttrs, this>) {
this.triggerElement = assertExists(findRef(dom, Tooltip.TRIGGER_REF));
}
onupdate({attrs}: m.VnodeDOM<TooltipAttrs, this>) {
this.createOrUpdatePopper(attrs);
}
onremove(_: m.VnodeDOM<TooltipAttrs, this>) {
this.triggerElement = undefined;
}
private createOrUpdatePopper(attrs: TooltipAttrs) {
const {
position = PopupPosition.Auto,
showArrow = true,
offset = 0,
edgeOffset = 0,
} = attrs;
const options: Partial<OptionsGeneric<ExtendedModifiers>> = {
placement: position,
modifiers: [
{
name: 'offset',
options: {
offset: ({placement}) => {
let skid = 0;
if (placement.includes('-end')) {
skid = edgeOffset;
} else if (placement.includes('-start')) {
skid = -edgeOffset;
}
return [skid, showArrow ? offset + 8 : offset];
},
},
},
{name: 'preventOverflow', options: {padding: 8}},
{name: 'arrow', options: {padding: 2}},
],
};
if (this.popper) {
this.popper.setOptions(options);
} else {
if (this.tooltipElement && this.triggerElement) {
this.popper = createPopper<ExtendedModifiers>(
this.triggerElement,
this.tooltipElement,
options,
);
}
}
}
}