mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-25 14:13:40 +09:00
1. Give menu items an inset pill highlight, text position and menu width are unchanged in most menus 1. Add arrow key, Enter, Space and Escape handling to all tippy menus 1. Mark the keyboard cursor of fomantic and tippy menus with the focus ring instead of the hover background 1. Mark the current dropdown item with the active color so it stands out from the hovered one 1. Stop navbar dropdown links from taking the navbar item hover color 1. Replace the actions job log options dropdown with the shared tippy menu, its hover highlight was invisible 1. Stop changing font weight on active and selected menu items, it resized rows while arrowing 1. Stretch the "All extensions" button in the diff file extension filter to the full menu width 1. Fix the actions run summary block covering the panel's rounded corners and indenting wrapped stats
322 lines
14 KiB
TypeScript
322 lines
14 KiB
TypeScript
import tippy, {followCursor} from 'tippy.js';
|
|
import {isDocumentFragmentOrElementNode, isElemVisible} 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`);
|
|
},
|
|
};
|
|
}
|
|
|
|
function isMenu(instance: Instance): boolean {
|
|
return instance.props.role === 'menu' && instance.props.theme === 'menu'; // role defaults to "menu" for every non-tooltip popup
|
|
}
|
|
|
|
function focusMenuItem(instance: Instance, delta: number) {
|
|
const items = [...instance.popper.querySelectorAll<HTMLElement>('.item:not(.disabled)')].filter(isElemVisible);
|
|
if (!items.length) return;
|
|
const current = items.indexOf(document.activeElement as HTMLElement);
|
|
const next = current === -1 ? (delta > 0 ? 0 : items.length - 1) : (current + delta + items.length) % items.length;
|
|
items[next].focus();
|
|
}
|
|
|
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
if (e.isComposing) return;
|
|
const menuInstance = [...visibleInstances].findLast(isMenu);
|
|
if (!menuInstance) return;
|
|
const focused = document.activeElement as HTMLElement;
|
|
const inMenu = menuInstance.popper.contains(focused);
|
|
if (!inMenu && focused !== menuInstance.reference && focused !== document.body) return; // body: macOS Safari and Firefox do not focus clicked buttons
|
|
if (e.key === 'Escape') {
|
|
menuInstance.hide();
|
|
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
focusMenuItem(menuInstance, e.key === 'ArrowDown' ? 1 : -1);
|
|
} else if ((e.key === 'Enter' || e.key === ' ') && inMenu) {
|
|
focused.click();
|
|
} else {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
});
|
|
|
|
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);
|
|
if (isMenu(instance) && instance.popper.contains(document.activeElement)) (instance.reference as HTMLElement).focus();
|
|
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);
|
|
if (isMenu(instance)) { // focusable by the arrow keys, out of the Tab order
|
|
for (const item of instance.popper.querySelectorAll<HTMLElement>('.item')) item.tabIndex = -1;
|
|
}
|
|
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;
|
|
}
|