snapshot of https://github.com/go-gitea/gitea.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped
Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped
This commit is contained in:
285
web_src/js/modules/tippy.ts
Normal file
285
web_src/js/modules/tippy.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import tippy, {followCursor} from 'tippy.js';
|
||||
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||
import type {Content, Instance, Placement, Props} from 'tippy.js';
|
||||
import {html, htmlEscape} from '../utils/html.ts';
|
||||
import {stripTags} from '../utils.ts';
|
||||
|
||||
type TippyOpts = {
|
||||
role?: string,
|
||||
theme?: 'default' | 'tooltip' | 'menu' | 'box-with-header' | 'bare',
|
||||
limitSizeToViewport?: {horizontal?: boolean, vertical?: boolean}, // cap to the viewport and scroll the content
|
||||
} & Partial<Props>;
|
||||
|
||||
type PopperModifier = NonNullable<NonNullable<Props['popperOptions']>['modifiers']>[number];
|
||||
|
||||
const visibleInstances = new Set<Instance>();
|
||||
const arrowSvg = html`<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
|
||||
|
||||
// shrink tippy's default 3px arrow padding so the arrow can point at the center of
|
||||
// narrow references like 16px icons with "start"/"end" placements
|
||||
function arrowPadding({placement, reference}: {placement: Placement, reference: {width: number, height: number}}): number {
|
||||
const isVertical = placement.startsWith('left') || placement.startsWith('right');
|
||||
const referenceLength = isVertical ? reference.height : reference.width;
|
||||
return Math.max(0, Math.min(3, referenceLength / 2 - 8)); // 8 = half of arrow width
|
||||
}
|
||||
|
||||
const viewportPadding = 8;
|
||||
|
||||
// space left on the side the popup opens towards, the viewport on the other axis
|
||||
export function availableSizeForPlacement(referenceRect: DOMRect, placement: string, offset: number): {width: number, height: number} {
|
||||
const gap = Math.abs(offset) + viewportPadding;
|
||||
const spanWidth = window.innerWidth - viewportPadding * 2;
|
||||
const spanHeight = window.innerHeight - viewportPadding * 2;
|
||||
const side = placement.split('-')[0];
|
||||
if (side === 'top') return {width: spanWidth, height: referenceRect.top - gap};
|
||||
if (side === 'bottom') return {width: spanWidth, height: window.innerHeight - referenceRect.bottom - gap};
|
||||
if (side === 'left') return {width: referenceRect.left - gap, height: spanHeight};
|
||||
return {width: window.innerWidth - referenceRect.right - gap, height: spanHeight};
|
||||
}
|
||||
|
||||
// popper does not constrain size, publish it for the styles. Replaced by floating-ui's "size" on migration
|
||||
function sizeModifier(limit: {horizontal?: boolean, vertical?: boolean}): PopperModifier {
|
||||
return {
|
||||
name: 'tippyLimitSize',
|
||||
enabled: true,
|
||||
phase: 'beforeWrite',
|
||||
requires: ['computeStyles'], // runs after "flip" and "offset", so both are final
|
||||
fn({state}) {
|
||||
const offset = state.modifiersData.offset?.[state.placement];
|
||||
const isVertical = state.placement.startsWith('top') || state.placement.startsWith('bottom');
|
||||
const available = availableSizeForPlacement(
|
||||
state.elements.reference.getBoundingClientRect(),
|
||||
state.placement,
|
||||
(isVertical ? offset?.y : offset?.x) ?? 0,
|
||||
);
|
||||
const {style} = state.elements.popper;
|
||||
if (limit.horizontal) style.setProperty('--tippy-max-width', `${Math.max(0, Math.floor(available.width))}px`);
|
||||
if (limit.vertical) style.setProperty('--tippy-max-height', `${Math.max(0, Math.floor(available.height))}px`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
|
||||
// the callback functions should be destructured from opts,
|
||||
// because we should use our own wrapper functions to handle them, do not let the user override them
|
||||
const {onHide, onShow, onDestroy, role, theme, arrow, limitSizeToViewport, ...other} = opts;
|
||||
// CSS theme, either "default", "tooltip", "menu", "box-with-header" or "bare"
|
||||
const resolvedTheme = theme || role || 'default';
|
||||
const resolvedArrow = arrow ?? (resolvedTheme === 'bare' ? false : arrowSvg);
|
||||
|
||||
const instance: Instance = tippy(target, {
|
||||
appendTo: document.body,
|
||||
animation: false,
|
||||
allowHTML: false,
|
||||
hideOnClick: false,
|
||||
interactiveBorder: 20,
|
||||
ignoreAttributes: true,
|
||||
maxWidth: 500, // increase over default 350px
|
||||
onHide: (instance: Instance) => {
|
||||
visibleInstances.delete(instance);
|
||||
return onHide?.(instance);
|
||||
},
|
||||
onDestroy: (instance: Instance) => {
|
||||
visibleInstances.delete(instance);
|
||||
return onDestroy?.(instance);
|
||||
},
|
||||
onShow: (instance: Instance) => {
|
||||
// hide other tooltip instances so only one tooltip shows at a time
|
||||
for (const visibleInstance of visibleInstances) {
|
||||
if (visibleInstance.props.role === 'tooltip') {
|
||||
visibleInstance.hide();
|
||||
}
|
||||
}
|
||||
visibleInstances.add(instance);
|
||||
target.setAttribute('aria-controls', instance.popper.id);
|
||||
return onShow?.(instance);
|
||||
},
|
||||
arrow: resolvedArrow,
|
||||
popperOptions: {modifiers: [
|
||||
{name: 'arrow', options: {padding: arrowPadding}},
|
||||
...limitSizeToViewport ? [sizeModifier(limitSizeToViewport)] : [],
|
||||
]},
|
||||
// HTML role attribute, ideally the default role would be "popover" but it does not exist
|
||||
role: role || 'menu',
|
||||
theme: resolvedTheme,
|
||||
offset: [0, resolvedArrow ? 10 : 6],
|
||||
plugins: [followCursor],
|
||||
...other,
|
||||
} satisfies Partial<Props>);
|
||||
|
||||
if (instance.props.role === 'menu') {
|
||||
target.setAttribute('aria-haspopup', 'true');
|
||||
}
|
||||
if (limitSizeToViewport) instance.popper.setAttribute('data-tippy-limit-size', '');
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a tooltip tippy to the given target element.
|
||||
* If the target element already has a tooltip tippy attached, the tooltip will be updated with the new content.
|
||||
* If the target element has no content, then no tooltip will be attached, and it returns null.
|
||||
*
|
||||
* Note: "tooltip" doesn't equal to "tippy". "tooltip" means a auto-popup content, it just uses tippy as the implementation.
|
||||
*/
|
||||
function attachTooltip(target: Element, content: Content | null = null): Instance | null {
|
||||
switchTitleToTooltip(target);
|
||||
|
||||
content = content ?? target.getAttribute('data-tooltip-content');
|
||||
if (!content) return null;
|
||||
|
||||
let allowHTML = target.getAttribute('data-tooltip-render') === 'html';
|
||||
if (!allowHTML && typeof content === 'string') {
|
||||
content = htmlEscape(content);
|
||||
content = content.replace(/\n/g, '<br>');
|
||||
allowHTML = true;
|
||||
}
|
||||
|
||||
// when element has a clipboard target, we update the tooltip after copy
|
||||
// in which case it is undesirable to automatically hide it on click as
|
||||
// it would momentarily flash the tooltip out and in.
|
||||
const hasClipboardTarget = target.hasAttribute('data-clipboard-target');
|
||||
const hideOnClick = !hasClipboardTarget;
|
||||
|
||||
const props: TippyOpts = {
|
||||
content,
|
||||
delay: 100,
|
||||
role: 'tooltip',
|
||||
theme: 'tooltip',
|
||||
hideOnClick,
|
||||
allowHTML,
|
||||
placement: target.getAttribute('data-tooltip-placement') as Placement || 'top',
|
||||
followCursor: target.getAttribute('data-tooltip-follow-cursor') as Props['followCursor'] || false,
|
||||
...((target.getAttribute('data-tooltip-interactive') === 'true') && {interactive: true, aria: {content: 'describedby', expanded: false}}),
|
||||
};
|
||||
|
||||
if (!target._tippy) {
|
||||
createTippy(target, props);
|
||||
} else {
|
||||
target._tippy.setProps(props);
|
||||
}
|
||||
return target._tippy;
|
||||
}
|
||||
|
||||
function switchTitleToTooltip(target: Element): void {
|
||||
const title = target.getAttribute('title');
|
||||
if (title) {
|
||||
target.setAttribute('data-tooltip-content', title);
|
||||
target.setAttribute('aria-label', title);
|
||||
target.setAttribute('title', '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating tooltip tippy instance is expensive, so we only create it when the user hovers over the element
|
||||
* According to https://www.w3.org/TR/DOM-Level-3-Events/#events-mouseevent-event-order , mouseover event is fired before mouseenter event
|
||||
* Some browsers like PaleMoon don't support "addEventListener('mouseenter', capture)"
|
||||
* The tippy by default uses "mouseenter" event to show, so we use "mouseover" event to switch to tippy
|
||||
*/
|
||||
function lazyTooltipOnMouseHover(e: Event): void {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.removeEventListener('mouseover', lazyTooltipOnMouseHover, true);
|
||||
// Firefox skips enter/leave dispatch when the window had no such listeners at the time of the
|
||||
// pointer crossing, so the new tippy misses its first "mouseenter". Show via a synthetic event,
|
||||
// carrying over the cursor position for "followCursor" tooltips.
|
||||
const {clientX, clientY} = e as MouseEvent;
|
||||
attachTooltip(el)?.reference.dispatchEvent(new MouseEvent('mouseenter', {clientX, clientY}));
|
||||
}
|
||||
|
||||
// Activate the tooltip for current element.
|
||||
// If the element has no aria-label, use the tooltip content as aria-label.
|
||||
function attachLazyTooltip(el: HTMLElement): void {
|
||||
el.addEventListener('mouseover', lazyTooltipOnMouseHover, {capture: true});
|
||||
|
||||
// meanwhile, if the element has no aria-label, use the tooltip content as aria-label
|
||||
if (!el.hasAttribute('aria-label')) {
|
||||
const content = el.getAttribute('data-tooltip-content');
|
||||
if (content) {
|
||||
const isHtml = el.getAttribute('data-tooltip-render') === 'html';
|
||||
let ariaLabelValue = content;
|
||||
if (isHtml) ariaLabelValue = stripTags(content).replace(/\s+/g, ' ').trim();
|
||||
el.setAttribute('aria-label', ariaLabelValue);
|
||||
el.removeAttribute('aria-hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Activate the tooltip for all children elements.
|
||||
function attachChildrenLazyTooltip(target: HTMLElement): void {
|
||||
for (const el of target.querySelectorAll<HTMLElement>('[data-tooltip-content]')) {
|
||||
attachLazyTooltip(el);
|
||||
}
|
||||
}
|
||||
|
||||
export function initGlobalTooltips(): void {
|
||||
// use MutationObserver to detect new "data-tooltip-content" elements added to the DOM, or attributes changed
|
||||
const observerConnect = (observer: MutationObserver) => observer.observe(document, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributeFilter: ['data-tooltip-content'],
|
||||
});
|
||||
const observer = new MutationObserver((mutationList, observer) => {
|
||||
const pending = observer.takeRecords();
|
||||
observer.disconnect();
|
||||
for (const mutation of [...mutationList, ...pending]) {
|
||||
if (mutation.type === 'childList') {
|
||||
// mainly for Vue components and AJAX rendered elements
|
||||
for (const el of mutation.addedNodes as NodeListOf<HTMLElement>) {
|
||||
if (!isDocumentFragmentOrElementNode(el)) continue;
|
||||
attachChildrenLazyTooltip(el);
|
||||
if (el.hasAttribute('data-tooltip-content')) {
|
||||
attachLazyTooltip(el);
|
||||
}
|
||||
}
|
||||
} else if (mutation.type === 'attributes') {
|
||||
attachTooltip(mutation.target as Element);
|
||||
}
|
||||
}
|
||||
observerConnect(observer);
|
||||
});
|
||||
observerConnect(observer);
|
||||
|
||||
attachChildrenLazyTooltip(document.documentElement);
|
||||
}
|
||||
|
||||
export function showTemporaryTooltip(target: Element, content: Content): void {
|
||||
// if the target is inside a dropdown or tippy popup, the menu will be hidden soon
|
||||
// so display the tooltip on the "aria-controls" element or dropdown instead
|
||||
let refClientRect: DOMRect | undefined;
|
||||
const popupTippyId = target.closest(`[data-tippy-root]`)?.id;
|
||||
if (popupTippyId) {
|
||||
// for example, the "Copy Permalink" button in the "File View" page for the selected lines
|
||||
target = document.body;
|
||||
refClientRect = document.querySelector(`[aria-controls="${CSS.escape(popupTippyId)}"]`)?.getBoundingClientRect();
|
||||
refClientRect = refClientRect ?? new DOMRect(0, 0, 0, 0); // fallback to empty rect if not found, tippy doesn't accept null
|
||||
} else {
|
||||
// for example, the "Copy Link" button in the issue header dropdown menu
|
||||
target = target.closest('.ui.dropdown') ?? target;
|
||||
refClientRect = target.getBoundingClientRect();
|
||||
}
|
||||
const tooltipTippy = target._tippy ?? attachTooltip(target, content);
|
||||
tooltipTippy.setContent(content);
|
||||
tooltipTippy.setProps({getReferenceClientRect: () => refClientRect});
|
||||
if (!tooltipTippy.state.isShown) tooltipTippy.show();
|
||||
|
||||
tooltipTippy.setProps({
|
||||
onHidden: (tippy) => {
|
||||
// reset the default tooltip content, if no default, then this temporary tooltip could be destroyed
|
||||
if (!attachTooltip(target)) {
|
||||
tippy.destroy();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// on elements where the tooltip is re-located like "Copy Link" inside fomantic dropdowns, tippy.js gets
|
||||
// no `mouseout` event and the tooltip stays visible, hide it with timeout.
|
||||
if (!popupTippyId) {
|
||||
setTimeout(() => {
|
||||
if (tooltipTippy.state.isVisible) tooltipTippy.hide();
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAttachedTippyInstance(el: Element): Instance | null {
|
||||
return el._tippy ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user