mirror of
https://github.com/microsoft/vscode.git
synced 2026-02-15 07:28:05 +00:00
hovers: allow nested hovers (#289747)
* hovers: allow nested hovers Feels a bit bizarre but it has a good use case in the agents sessions view where the hover is more interactive than usual. Previously, hovering any hoverable element within the hover would close the hover. Now, hovering a hoverable element within a hover will open a nested hover by default (capped at 3 levels.) It seems to work well. * make hovers not disappear when hovering inside nested element * tests --------- Co-authored-by: BeniBenj <besimmonds@microsoft.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import { IHoverService } from './hover.js';
|
||||
import { IContextMenuService } from '../../contextview/browser/contextView.js';
|
||||
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
|
||||
import { HoverWidget } from './hoverWidget.js';
|
||||
import { IContextViewProvider, IDelegate } from '../../../base/browser/ui/contextview/contextview.js';
|
||||
import { ContextView, ContextViewDOMPosition, IDelegate } from '../../../base/browser/ui/contextview/contextview.js';
|
||||
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
|
||||
import { addDisposableListener, EventType, getActiveElement, isAncestorOfActiveElement, isAncestor, getWindow, isHTMLElement, isEditableElement } from '../../../base/browser/dom.js';
|
||||
import { IKeybindingService } from '../../keybinding/common/keybinding.js';
|
||||
@@ -19,7 +19,6 @@ import { ResultKind } from '../../keybinding/common/keybindingResolver.js';
|
||||
import { IAccessibilityService } from '../../accessibility/common/accessibility.js';
|
||||
import { ILayoutService } from '../../layout/browser/layoutService.js';
|
||||
import { mainWindow } from '../../../base/browser/window.js';
|
||||
import { ContextViewHandler } from '../../contextview/browser/contextViewService.js';
|
||||
import { HoverStyle, isManagedHoverTooltipMarkdownString, type IHoverLifecycleOptions, type IHoverOptions, type IHoverTarget, type IHoverWidget, type IManagedHover, type IManagedHoverContentOrFactory, type IManagedHoverOptions } from '../../../base/browser/ui/hover/hover.js';
|
||||
import type { IHoverDelegate, IHoverDelegateTarget } from '../../../base/browser/ui/hover/hoverDelegate.js';
|
||||
import { ManagedHoverWidget } from './updatableHoverWidget.js';
|
||||
@@ -31,22 +30,77 @@ import { KeybindingsRegistry, KeybindingWeight } from '../../keybinding/common/k
|
||||
import { IMarkdownString } from '../../../base/common/htmlContent.js';
|
||||
import { stripIcons } from '../../../base/common/iconLabels.js';
|
||||
|
||||
/**
|
||||
* Maximum nesting depth for hovers. This prevents runaway nesting.
|
||||
*/
|
||||
const MAX_HOVER_NESTING_DEPTH = 3;
|
||||
|
||||
/**
|
||||
* An entry in the hover stack, representing a single hover and its associated state.
|
||||
*/
|
||||
interface IHoverStackEntry {
|
||||
readonly hover: HoverWidget;
|
||||
readonly options: IHoverOptions;
|
||||
readonly contextView: ContextView;
|
||||
readonly lastFocusedElementBeforeOpen: HTMLElement | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating a hover, containing the hover widget and associated state.
|
||||
*/
|
||||
interface ICreateHoverResult {
|
||||
readonly hover: HoverWidget;
|
||||
readonly store: DisposableStore;
|
||||
readonly lastFocusedElementBeforeOpen: HTMLElement | undefined;
|
||||
}
|
||||
|
||||
export class HoverService extends Disposable implements IHoverService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private _contextViewHandler: IContextViewProvider;
|
||||
private _currentHoverOptions: IHoverOptions | undefined;
|
||||
private _currentHover: HoverWidget | undefined;
|
||||
/**
|
||||
* Stack of currently visible hovers. The last entry is the topmost hover.
|
||||
* This enables nested hovers where hovering inside a hover can show another hover.
|
||||
*/
|
||||
private readonly _hoverStack: IHoverStackEntry[] = [];
|
||||
|
||||
private _currentDelayedHover: HoverWidget | undefined;
|
||||
private _currentDelayedHoverWasShown: boolean = false;
|
||||
private _currentDelayedHoverGroupId: number | string | undefined;
|
||||
private _lastHoverOptions: IHoverOptions | undefined;
|
||||
|
||||
private _lastFocusedElementBeforeOpen: HTMLElement | undefined;
|
||||
|
||||
private readonly _delayedHovers = new Map<HTMLElement, { show: (focus: boolean) => void }>();
|
||||
private readonly _managedHovers = new Map<HTMLElement, IManagedHover>();
|
||||
|
||||
/**
|
||||
* Gets the current (topmost) hover from the stack, if any.
|
||||
*/
|
||||
private get _currentHover(): HoverWidget | undefined {
|
||||
return this._hoverStack.at(-1)?.hover;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current (topmost) hover options from the stack, if any.
|
||||
*/
|
||||
private get _currentHoverOptions(): IHoverOptions | undefined {
|
||||
return this._hoverStack.at(-1)?.options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the target element is inside any of the hovers in the stack.
|
||||
* If it is, returns the index of the containing hover, otherwise returns -1.
|
||||
*/
|
||||
private _getContainingHoverIndex(target: HTMLElement | IHoverTarget): number {
|
||||
const targetElements = isHTMLElement(target) ? [target] : target.targetElements;
|
||||
// Search from top of stack to bottom (most recent hover first)
|
||||
for (let i = this._hoverStack.length - 1; i >= 0; i--) {
|
||||
for (const targetElement of targetElements) {
|
||||
if (isAncestor(targetElement, this._hoverStack[i].hover.domNode)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
constructor(
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@@ -58,7 +112,6 @@ export class HoverService extends Disposable implements IHoverService {
|
||||
super();
|
||||
|
||||
this._register(contextMenuService.onDidShowContextMenu(() => this.hideHover()));
|
||||
this._contextViewHandler = this._register(new ContextViewHandler(this._layoutService));
|
||||
|
||||
this._register(KeybindingsRegistry.registerCommandAndKeybindingRule({
|
||||
id: 'workbench.action.showHover',
|
||||
@@ -74,7 +127,7 @@ export class HoverService extends Disposable implements IHoverService {
|
||||
return undefined;
|
||||
}
|
||||
this._showHover(hover, options, focus);
|
||||
return hover;
|
||||
return hover.hover;
|
||||
}
|
||||
|
||||
showDelayedHover(
|
||||
@@ -120,18 +173,18 @@ export class HoverService extends Disposable implements IHoverService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this._currentDelayedHover = hover;
|
||||
this._currentDelayedHover = hover.hover;
|
||||
this._currentDelayedHoverWasShown = false;
|
||||
this._currentDelayedHoverGroupId = lifecycleOptions?.groupId;
|
||||
|
||||
timeout(this._configurationService.getValue<number>('workbench.hover.delay')).then(() => {
|
||||
if (hover && !hover.isDisposed) {
|
||||
if (hover.hover && !hover.hover.isDisposed) {
|
||||
this._currentDelayedHoverWasShown = true;
|
||||
this._showHover(hover, options);
|
||||
}
|
||||
});
|
||||
|
||||
return hover;
|
||||
return hover.hover;
|
||||
}
|
||||
|
||||
setupDelayedHover(
|
||||
@@ -190,37 +243,50 @@ export class HoverService extends Disposable implements IHoverService {
|
||||
return store;
|
||||
}
|
||||
|
||||
private _createHover(options: IHoverOptions, skipLastFocusedUpdate?: boolean): HoverWidget | undefined {
|
||||
private _createHover(options: IHoverOptions, skipLastFocusedUpdate?: boolean): ICreateHoverResult | undefined {
|
||||
this._currentDelayedHover = undefined;
|
||||
|
||||
if (options.content === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this._currentHover?.isLocked) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Set `id` to default if it's undefined
|
||||
if (options.id === undefined) {
|
||||
options.id = getHoverIdFromContent(options.content);
|
||||
}
|
||||
|
||||
if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {
|
||||
return undefined;
|
||||
// Check if the target is inside an existing hover (nesting scenario)
|
||||
const containingHoverIndex = this._getContainingHoverIndex(options.target);
|
||||
const isNesting = containingHoverIndex >= 0;
|
||||
|
||||
if (isNesting) {
|
||||
// Check max nesting depth
|
||||
if (this._hoverStack.length >= MAX_HOVER_NESTING_DEPTH) {
|
||||
return undefined;
|
||||
}
|
||||
// When nesting, don't check if the parent is locked - we allow nested hovers inside locked parents
|
||||
} else {
|
||||
// Not nesting: check if current top-level hover is locked
|
||||
if (this._currentHover?.isLocked) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if identity is the same as current hover
|
||||
if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
this._currentHoverOptions = options;
|
||||
|
||||
this._lastHoverOptions = options;
|
||||
const trapFocus = options.trapFocus || this._accessibilityService.isScreenReaderOptimized();
|
||||
const activeElement = getActiveElement();
|
||||
let lastFocusedElementBeforeOpen: HTMLElement | undefined;
|
||||
// HACK, remove this check when #189076 is fixed
|
||||
if (!skipLastFocusedUpdate) {
|
||||
if (trapFocus && activeElement) {
|
||||
if (!activeElement.classList.contains('monaco-hover')) {
|
||||
this._lastFocusedElementBeforeOpen = activeElement as HTMLElement;
|
||||
lastFocusedElementBeforeOpen = activeElement as HTMLElement;
|
||||
}
|
||||
} else {
|
||||
this._lastFocusedElementBeforeOpen = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,39 +305,48 @@ export class HoverService extends Disposable implements IHoverService {
|
||||
}
|
||||
|
||||
hover.onDispose(() => {
|
||||
const hoverWasFocused = this._currentHover?.domNode && isAncestorOfActiveElement(this._currentHover.domNode);
|
||||
if (hoverWasFocused) {
|
||||
// Required to handle cases such as closing the hover with the escape key
|
||||
this._lastFocusedElementBeforeOpen?.focus();
|
||||
}
|
||||
|
||||
// Only clear the current options if it's the current hover, the current options help
|
||||
// reduce flickering when the same hover is shown multiple times
|
||||
if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {
|
||||
this.doHideHover();
|
||||
// Pop this hover from the stack if it's still there
|
||||
const stackIndex = this._hoverStack.findIndex(entry => entry.hover === hover);
|
||||
if (stackIndex >= 0) {
|
||||
const entry = this._hoverStack[stackIndex];
|
||||
// Restore focus if this hover was focused
|
||||
const hoverWasFocused = isAncestorOfActiveElement(hover.domNode);
|
||||
if (hoverWasFocused && entry.lastFocusedElementBeforeOpen) {
|
||||
entry.lastFocusedElementBeforeOpen.focus();
|
||||
}
|
||||
// Also dispose all nested hovers (hovers at higher indices in the stack)
|
||||
// Dispose from end to avoid index shifting issues
|
||||
while (this._hoverStack.length > stackIndex + 1) {
|
||||
const nestedEntry = this._hoverStack.pop()!;
|
||||
nestedEntry.contextView.dispose();
|
||||
nestedEntry.hover.dispose();
|
||||
}
|
||||
// Remove this hover from stack and dispose its context view
|
||||
this._hoverStack.splice(stackIndex, 1);
|
||||
entry.contextView.dispose();
|
||||
}
|
||||
hoverDisposables.dispose();
|
||||
}, undefined, hoverDisposables);
|
||||
|
||||
// Set the container explicitly to enable aux window support
|
||||
if (!options.container) {
|
||||
const targetElement = isHTMLElement(options.target) ? options.target : options.target.targetElements[0];
|
||||
options.container = this._layoutService.getContainer(getWindow(targetElement));
|
||||
}
|
||||
|
||||
hover.onRequestLayout(() => this._contextViewHandler.layout(), undefined, hoverDisposables);
|
||||
if (options.persistence?.sticky) {
|
||||
hoverDisposables.add(addDisposableListener(getWindow(options.container).document, EventType.MOUSE_DOWN, e => {
|
||||
if (!isAncestor(e.target as HTMLElement, hover.domNode)) {
|
||||
this.doHideHover();
|
||||
this._hideHoverAndDescendants(hover);
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
if ('targetElements' in options.target) {
|
||||
for (const element of options.target.targetElements) {
|
||||
hoverDisposables.add(addDisposableListener(element, EventType.CLICK, () => this.hideHover()));
|
||||
hoverDisposables.add(addDisposableListener(element, EventType.CLICK, () => this._hideHoverAndDescendants(hover)));
|
||||
}
|
||||
} else {
|
||||
hoverDisposables.add(addDisposableListener(options.target, EventType.CLICK, () => this.hideHover()));
|
||||
hoverDisposables.add(addDisposableListener(options.target, EventType.CLICK, () => this._hideHoverAndDescendants(hover)));
|
||||
}
|
||||
const focusedElement = getActiveElement();
|
||||
if (focusedElement) {
|
||||
@@ -290,30 +365,111 @@ export class HoverService extends Disposable implements IHoverService {
|
||||
hoverDisposables.add(toDisposable(() => observer.disconnect()));
|
||||
}
|
||||
|
||||
this._currentHover = hover;
|
||||
|
||||
return hover;
|
||||
return { hover, lastFocusedElementBeforeOpen, store: hoverDisposables };
|
||||
}
|
||||
|
||||
private _showHover(hover: HoverWidget, options: IHoverOptions, focus?: boolean) {
|
||||
this._contextViewHandler.showContextView(
|
||||
new HoverContextViewDelegate(hover, focus),
|
||||
options.container
|
||||
);
|
||||
private _showHover(result: ICreateHoverResult, options: IHoverOptions, focus?: boolean) {
|
||||
const { hover, lastFocusedElementBeforeOpen, store } = result;
|
||||
|
||||
// Check if the target is inside an existing hover (nesting scenario)
|
||||
const containingHoverIndex = this._getContainingHoverIndex(options.target);
|
||||
const isNesting = containingHoverIndex >= 0;
|
||||
|
||||
// If not nesting, close all existing hovers first
|
||||
if (!isNesting) {
|
||||
this._hideAllHovers();
|
||||
} else {
|
||||
// When nesting, close any sibling hovers (hovers at the same level or deeper
|
||||
// than the containing hover). This ensures hovers within the same container
|
||||
// are exclusive.
|
||||
for (let i = this._hoverStack.length - 1; i > containingHoverIndex; i--) {
|
||||
this._hoverStack[i].hover.dispose();
|
||||
}
|
||||
this._hoverStack.length = containingHoverIndex + 1;
|
||||
}
|
||||
|
||||
// When nesting, add the new hover's container to all parent hovers' mouse trackers.
|
||||
// This makes the parent hovers treat the nested hover as part of themselves,
|
||||
// so they won't close when the mouse moves into the nested hover.
|
||||
if (isNesting) {
|
||||
for (let i = 0; i <= containingHoverIndex; i++) {
|
||||
store.add(this._hoverStack[i].hover.addMouseTrackingElement(hover.domNode));
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new ContextView for this hover with higher z-index for nested hovers
|
||||
const container = options.container ?? this._layoutService.getContainer(getWindow(isHTMLElement(options.target) ? options.target : options.target.targetElements[0]));
|
||||
const contextView = new ContextView(container, ContextViewDOMPosition.ABSOLUTE);
|
||||
|
||||
// Push to stack
|
||||
const stackEntry: IHoverStackEntry = {
|
||||
hover,
|
||||
options,
|
||||
contextView,
|
||||
lastFocusedElementBeforeOpen
|
||||
};
|
||||
this._hoverStack.push(stackEntry);
|
||||
|
||||
// Show the hover in its context view
|
||||
const delegate = new HoverContextViewDelegate(hover, focus, this._hoverStack.length);
|
||||
contextView.show(delegate);
|
||||
|
||||
// Set up layout handling
|
||||
store.add(hover.onRequestLayout(() => contextView.layout()));
|
||||
|
||||
options.onDidShow?.();
|
||||
}
|
||||
|
||||
hideHover(force?: boolean): void {
|
||||
if ((!force && this._currentHover?.isLocked) || !this._currentHoverOptions) {
|
||||
/**
|
||||
* Hides a specific hover and all hovers nested inside it.
|
||||
*/
|
||||
private _hideHoverAndDescendants(hover: HoverWidget): void {
|
||||
const stackIndex = this._hoverStack.findIndex(entry => entry.hover === hover);
|
||||
if (stackIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispose all hovers from this index onwards (including nested ones)
|
||||
for (let i = this._hoverStack.length - 1; i >= stackIndex; i--) {
|
||||
this._hoverStack[i].hover.dispose();
|
||||
}
|
||||
this._hoverStack.length = stackIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides all hovers in the stack.
|
||||
*/
|
||||
private _hideAllHovers(): void {
|
||||
for (let i = this._hoverStack.length - 1; i >= 0; i--) {
|
||||
this._hoverStack[i].hover.dispose();
|
||||
}
|
||||
this._hoverStack.length = 0;
|
||||
}
|
||||
|
||||
hideHover(force?: boolean): void {
|
||||
if (this._hoverStack.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If not forcing and the topmost hover is locked, don't hide
|
||||
if (!force && this._currentHover?.isLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide only the topmost hover (pop from stack)
|
||||
this.doHideHover();
|
||||
}
|
||||
|
||||
private doHideHover(): void {
|
||||
this._currentHover = undefined;
|
||||
this._currentHoverOptions = undefined;
|
||||
this._contextViewHandler.hideContextView();
|
||||
// Pop and dispose the topmost hover
|
||||
const length = this._hoverStack.length;
|
||||
this._hoverStack[length - 1]?.hover.dispose();
|
||||
this._hoverStack.length = length - 1;
|
||||
|
||||
// After popping a nested hover, unlock the parent if it was locked due to nesting
|
||||
// (Note: the parent may have been explicitly locked via sticky, so we only unlock
|
||||
// if there are remaining hovers and they're not sticky)
|
||||
// For simplicity, we don't auto-unlock here - the parent remains in its current lock state
|
||||
}
|
||||
|
||||
private _intersectionChange(entries: IntersectionObserverEntry[], hover: IDisposable): void {
|
||||
@@ -347,7 +503,10 @@ export class HoverService extends Disposable implements IHoverService {
|
||||
|
||||
private _keyDown(e: KeyboardEvent, hover: HoverWidget, hideOnKeyDown: boolean) {
|
||||
if (e.key === 'Alt') {
|
||||
hover.isLocked = true;
|
||||
// Lock all hovers in the stack when Alt is pressed
|
||||
for (const entry of this._hoverStack) {
|
||||
entry.hover.isLocked = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const event = new StandardKeyboardEvent(e);
|
||||
@@ -356,18 +515,28 @@ export class HoverService extends Disposable implements IHoverService {
|
||||
return;
|
||||
}
|
||||
if (hideOnKeyDown && (!this._currentHoverOptions?.trapFocus || e.key !== 'Tab')) {
|
||||
this.hideHover();
|
||||
this._lastFocusedElementBeforeOpen?.focus();
|
||||
// Find the entry for this hover to get its lastFocusedElementBeforeOpen
|
||||
const stackEntry = this._hoverStack.find(entry => entry.hover === hover);
|
||||
this._hideHoverAndDescendants(hover);
|
||||
stackEntry?.lastFocusedElementBeforeOpen?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private _keyUp(e: KeyboardEvent, hover: HoverWidget) {
|
||||
if (e.key === 'Alt') {
|
||||
hover.isLocked = false;
|
||||
// Hide if alt is released while the mouse is not over hover/target
|
||||
if (!hover.isMouseIn) {
|
||||
this.hideHover();
|
||||
this._lastFocusedElementBeforeOpen?.focus();
|
||||
// Unlock all hovers in the stack when Alt is released
|
||||
for (const entry of this._hoverStack) {
|
||||
// Only unlock if not sticky
|
||||
if (!entry.options.persistence?.sticky) {
|
||||
entry.hover.isLocked = false;
|
||||
}
|
||||
}
|
||||
// Hide all hovers if the mouse is not over any of them
|
||||
const anyMouseIn = this._hoverStack.some(entry => entry.hover.isMouseIn);
|
||||
if (!anyMouseIn) {
|
||||
const topEntry = this._hoverStack[this._hoverStack.length - 1];
|
||||
this._hideAllHovers();
|
||||
topEntry?.lastFocusedElementBeforeOpen?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -572,8 +741,8 @@ function setupNativeHover(targetElement: HTMLElement, content: IManagedHoverCont
|
||||
|
||||
class HoverContextViewDelegate implements IDelegate {
|
||||
|
||||
// Render over all other context views
|
||||
public readonly layer = 1;
|
||||
// Render over all other context views, with higher layers for nested hovers
|
||||
public readonly layer: number;
|
||||
|
||||
get anchorPosition() {
|
||||
return this._hover.anchor;
|
||||
@@ -581,8 +750,11 @@ class HoverContextViewDelegate implements IDelegate {
|
||||
|
||||
constructor(
|
||||
private readonly _hover: HoverWidget,
|
||||
private readonly _focus: boolean = false
|
||||
private readonly _focus: boolean = false,
|
||||
stackDepth: number = 1
|
||||
) {
|
||||
// Base layer is 1, nested hovers get higher layers
|
||||
this.layer = stackDepth;
|
||||
}
|
||||
|
||||
render(container: HTMLElement) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './hover.css';
|
||||
import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js';
|
||||
import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js';
|
||||
import { Event, Emitter } from '../../../base/common/event.js';
|
||||
import * as dom from '../../../base/browser/dom.js';
|
||||
import { IKeybindingService } from '../../keybinding/common/keybinding.js';
|
||||
@@ -93,6 +93,16 @@ export class HoverWidget extends Widget implements IHoverWidget {
|
||||
this._hoverContainer.classList.toggle('locked', this._isLocked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an element to be tracked by this hover's mouse tracker. Mouse events on
|
||||
* this element will be considered as being "inside" the hover, preventing it
|
||||
* from closing. This is used for nested hovers where the child hover's container
|
||||
* should be treated as part of the parent hover.
|
||||
*/
|
||||
addMouseTrackingElement(element: HTMLElement): IDisposable {
|
||||
return this._lockMouseTracker.addElement(element);
|
||||
}
|
||||
|
||||
constructor(
|
||||
options: IHoverOptions,
|
||||
@IKeybindingService private readonly _keybindingService: IKeybindingService,
|
||||
@@ -688,6 +698,27 @@ class CompositeMouseTracker extends Widget {
|
||||
this._onMouseOut.fire();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an element to be tracked by this mouse tracker. Mouse events on this
|
||||
* element will be considered as being "inside" the tracked area.
|
||||
*/
|
||||
addElement(element: HTMLElement): IDisposable {
|
||||
if (this._elements.includes(element)) {
|
||||
return Disposable.None;
|
||||
}
|
||||
this._elements.push(element);
|
||||
const store = new DisposableStore();
|
||||
store.add(dom.addDisposableListener(element, dom.EventType.MOUSE_OVER, () => this._onTargetMouseOver()));
|
||||
store.add(dom.addDisposableListener(element, dom.EventType.MOUSE_LEAVE, () => this._onTargetMouseLeave()));
|
||||
store.add(toDisposable(() => {
|
||||
const index = this._elements.indexOf(element);
|
||||
if (index >= 0) {
|
||||
this._elements.splice(index, 1);
|
||||
}
|
||||
}));
|
||||
return store;
|
||||
}
|
||||
}
|
||||
|
||||
class ElementHoverTarget implements IHoverTarget {
|
||||
|
||||
563
src/vs/platform/hover/test/browser/hoverService.test.ts
Normal file
563
src/vs/platform/hover/test/browser/hoverService.test.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { Event } from '../../../../base/common/event.js';
|
||||
import { toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { timeout } from '../../../../base/common/async.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';
|
||||
import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';
|
||||
import { IConfigurationService } from '../../../configuration/common/configuration.js';
|
||||
import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js';
|
||||
import { HoverService } from '../../browser/hoverService.js';
|
||||
import { HoverWidget } from '../../browser/hoverWidget.js';
|
||||
import { IContextMenuService } from '../../../contextview/browser/contextView.js';
|
||||
import { IKeybindingService } from '../../../keybinding/common/keybinding.js';
|
||||
import { ILayoutService } from '../../../layout/browser/layoutService.js';
|
||||
import { IAccessibilityService } from '../../../accessibility/common/accessibility.js';
|
||||
import { TestAccessibilityService } from '../../../accessibility/test/common/testAccessibilityService.js';
|
||||
import { mainWindow } from '../../../../base/browser/window.js';
|
||||
import { NoMatchingKb } from '../../../keybinding/common/keybindingResolver.js';
|
||||
import { IMarkdownRendererService } from '../../../markdown/browser/markdownRenderer.js';
|
||||
import type { IHoverWidget } from '../../../../base/browser/ui/hover/hover.js';
|
||||
|
||||
suite('HoverService', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
let hoverService: HoverService;
|
||||
let fixture: HTMLElement;
|
||||
let instantiationService: TestInstantiationService;
|
||||
|
||||
setup(() => {
|
||||
fixture = document.createElement('div');
|
||||
mainWindow.document.body.appendChild(fixture);
|
||||
store.add(toDisposable(() => fixture.remove()));
|
||||
|
||||
instantiationService = store.add(new TestInstantiationService());
|
||||
|
||||
const configurationService = new TestConfigurationService();
|
||||
configurationService.setUserConfiguration('workbench.hover.delay', 0);
|
||||
instantiationService.stub(IConfigurationService, configurationService);
|
||||
|
||||
instantiationService.stub(IContextMenuService, {
|
||||
onDidShowContextMenu: Event.None
|
||||
});
|
||||
|
||||
instantiationService.stub(IKeybindingService, {
|
||||
mightProducePrintableCharacter() { return false; },
|
||||
softDispatch() { return NoMatchingKb; },
|
||||
resolveKeyboardEvent() {
|
||||
return {
|
||||
getLabel() { return ''; },
|
||||
getAriaLabel() { return ''; },
|
||||
getElectronAccelerator() { return null; },
|
||||
getUserSettingsLabel() { return null; },
|
||||
isWYSIWYG() { return false; },
|
||||
hasMultipleChords() { return false; },
|
||||
getDispatchChords() { return [null]; },
|
||||
getSingleModifierDispatchChords() { return []; },
|
||||
getChords() { return []; }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
instantiationService.stub(ILayoutService, {
|
||||
activeContainer: fixture,
|
||||
mainContainer: fixture,
|
||||
getContainer() { return fixture; },
|
||||
onDidLayoutContainer: Event.None
|
||||
});
|
||||
|
||||
instantiationService.stub(IAccessibilityService, new TestAccessibilityService());
|
||||
|
||||
instantiationService.stub(IMarkdownRendererService, {
|
||||
render() { return { element: document.createElement('div'), dispose() { } }; },
|
||||
setDefaultCodeBlockRenderer() { }
|
||||
});
|
||||
|
||||
hoverService = store.add(instantiationService.createInstance(HoverService));
|
||||
});
|
||||
|
||||
// #region Helper functions
|
||||
|
||||
function createTarget(): HTMLElement {
|
||||
const target = document.createElement('div');
|
||||
target.style.width = '100px';
|
||||
target.style.height = '100px';
|
||||
fixture.appendChild(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
function showHover(content: string, target?: HTMLElement, options?: Partial<Parameters<typeof hoverService.showInstantHover>[0]>): IHoverWidget {
|
||||
const hover = hoverService.showInstantHover({
|
||||
content,
|
||||
target: target ?? createTarget(),
|
||||
...options
|
||||
});
|
||||
assert.ok(hover, `Hover with content "${content}" should be created`);
|
||||
return hover;
|
||||
}
|
||||
|
||||
function asHoverWidget(hover: IHoverWidget): HoverWidget {
|
||||
return hover as HoverWidget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a hover's DOM node is present in the document.
|
||||
*/
|
||||
function isInDOM(hover: IHoverWidget): boolean {
|
||||
return mainWindow.document.body.contains(asHoverWidget(hover).domNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a hover is in the DOM.
|
||||
*/
|
||||
function assertInDOM(hover: IHoverWidget, message?: string): void {
|
||||
assert.ok(isInDOM(hover), message ?? 'Hover should be in the DOM');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a hover is NOT in the DOM.
|
||||
*/
|
||||
function assertNotInDOM(hover: IHoverWidget, message?: string): void {
|
||||
assert.ok(!isInDOM(hover), message ?? 'Hover should not be in the DOM');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a nested hover by appending a target element inside the parent hover's DOM.
|
||||
*/
|
||||
function createNestedHover(parentHover: IHoverWidget, content: string): IHoverWidget {
|
||||
const nestedTarget = document.createElement('div');
|
||||
asHoverWidget(parentHover).domNode.appendChild(nestedTarget);
|
||||
return showHover(content, nestedTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a chain of nested hovers up to the specified depth.
|
||||
* Returns the array of hovers from outermost to innermost.
|
||||
*/
|
||||
function createHoverChain(depth: number): HoverWidget[] {
|
||||
const hovers: HoverWidget[] = [];
|
||||
let currentTarget: HTMLElement = createTarget();
|
||||
|
||||
for (let i = 0; i < depth; i++) {
|
||||
const hover = hoverService.showInstantHover({
|
||||
content: `Hover ${i + 1}`,
|
||||
target: currentTarget
|
||||
});
|
||||
if (!hover) {
|
||||
break;
|
||||
}
|
||||
hovers.push(asHoverWidget(hover));
|
||||
currentTarget = document.createElement('div');
|
||||
asHoverWidget(hover).domNode.appendChild(currentTarget);
|
||||
}
|
||||
|
||||
return hovers;
|
||||
}
|
||||
|
||||
function disposeHovers(hovers: HoverWidget[]): void {
|
||||
for (const h of [...hovers].reverse()) {
|
||||
h?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
suite('showInstantHover', () => {
|
||||
test('should not show hover with empty content', () => {
|
||||
const target = createTarget();
|
||||
const hover = hoverService.showInstantHover({
|
||||
content: '',
|
||||
target
|
||||
});
|
||||
|
||||
assert.strictEqual(hover, undefined, 'Hover should not be created for empty content');
|
||||
});
|
||||
|
||||
test('should call onDidShow callback when hover is shown', () => {
|
||||
const target = createTarget();
|
||||
let didShowCalled = false;
|
||||
|
||||
const hover = hoverService.showInstantHover({
|
||||
content: 'Test',
|
||||
target,
|
||||
onDidShow: () => { didShowCalled = true; }
|
||||
});
|
||||
|
||||
assert.ok(didShowCalled, 'onDidShow should be called');
|
||||
assert.ok(hover);
|
||||
assertInDOM(hover, 'Hover should be in DOM after showing');
|
||||
|
||||
hover.dispose();
|
||||
assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');
|
||||
});
|
||||
|
||||
test('should deduplicate hovers by id', () => {
|
||||
const target = createTarget();
|
||||
|
||||
const hover1 = hoverService.showInstantHover({
|
||||
content: 'Same content',
|
||||
target,
|
||||
id: 'same-id'
|
||||
});
|
||||
|
||||
const hover2 = hoverService.showInstantHover({
|
||||
content: 'Same content',
|
||||
target,
|
||||
id: 'same-id'
|
||||
});
|
||||
|
||||
assert.ok(hover1, 'First hover should be created');
|
||||
assertInDOM(hover1, 'First hover should be in DOM');
|
||||
assert.strictEqual(hover2, undefined, 'Second hover with same id should not be created');
|
||||
|
||||
// Different id should create new hover
|
||||
const hover3 = hoverService.showInstantHover({
|
||||
content: 'Content 3',
|
||||
target,
|
||||
id: 'different-id'
|
||||
});
|
||||
|
||||
assert.ok(hover3, 'Hover with different id should be created');
|
||||
assertInDOM(hover3, 'Third hover should be in DOM');
|
||||
|
||||
hover1?.dispose();
|
||||
hover3?.dispose();
|
||||
});
|
||||
|
||||
test('should apply additional classes to hover DOM', () => {
|
||||
const hover = showHover('Test', undefined, {
|
||||
additionalClasses: ['custom-class-1', 'custom-class-2']
|
||||
});
|
||||
|
||||
const domNode = asHoverWidget(hover).domNode;
|
||||
assertInDOM(hover, 'Hover should be in DOM');
|
||||
assert.ok(domNode.classList.contains('custom-class-1'), 'Should have custom-class-1');
|
||||
assert.ok(domNode.classList.contains('custom-class-2'), 'Should have custom-class-2');
|
||||
|
||||
hover.dispose();
|
||||
assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');
|
||||
});
|
||||
});
|
||||
|
||||
suite('hideHover', () => {
|
||||
test('should hide non-locked hover', () => {
|
||||
const hover = showHover('Test');
|
||||
assertInDOM(hover, 'Hover should be in DOM initially');
|
||||
|
||||
hoverService.hideHover();
|
||||
|
||||
assert.strictEqual(hover.isDisposed, true, 'Hover should be disposed after hideHover');
|
||||
assertNotInDOM(hover, 'Hover should be removed from DOM after hideHover');
|
||||
});
|
||||
|
||||
test('should not hide locked hover without force flag', () => {
|
||||
const hover = showHover('Test', undefined, {
|
||||
persistence: { sticky: true }
|
||||
});
|
||||
assertInDOM(hover, 'Locked hover should be in DOM');
|
||||
|
||||
hoverService.hideHover();
|
||||
assert.strictEqual(hover.isDisposed, false, 'Locked hover should not be disposed without force');
|
||||
assertInDOM(hover, 'Locked hover should remain in DOM');
|
||||
|
||||
hoverService.hideHover(true);
|
||||
assert.strictEqual(hover.isDisposed, true, 'Locked hover should be disposed with force=true');
|
||||
assertNotInDOM(hover, 'Locked hover should be removed from DOM with force');
|
||||
});
|
||||
});
|
||||
|
||||
suite('nested hovers', () => {
|
||||
test('should keep parent hover visible when nested hover is created', () => {
|
||||
const parentHover = showHover('Parent');
|
||||
assertInDOM(parentHover, 'Parent hover should be in DOM');
|
||||
|
||||
const nestedHover = createNestedHover(parentHover, 'Nested');
|
||||
assertInDOM(nestedHover, 'Nested hover should be in DOM');
|
||||
assertInDOM(parentHover, 'Parent hover should still be in DOM after nested hover created');
|
||||
|
||||
assert.strictEqual(parentHover.isDisposed, false, 'Parent hover should remain visible');
|
||||
assert.strictEqual(nestedHover.isDisposed, false, 'Nested hover should be visible');
|
||||
|
||||
nestedHover.dispose();
|
||||
assertNotInDOM(nestedHover, 'Nested hover should be removed from DOM after dispose');
|
||||
assertInDOM(parentHover, 'Parent hover should remain in DOM after nested is disposed');
|
||||
|
||||
parentHover.dispose();
|
||||
assertNotInDOM(parentHover, 'Parent hover should be removed from DOM after dispose');
|
||||
});
|
||||
|
||||
test('should dispose nested hover when parent is disposed', () => {
|
||||
const parentHover = showHover('Parent');
|
||||
const nestedHover = createNestedHover(parentHover, 'Nested');
|
||||
|
||||
assertInDOM(parentHover, 'Parent hover should be in DOM');
|
||||
assertInDOM(nestedHover, 'Nested hover should be in DOM');
|
||||
|
||||
parentHover.dispose();
|
||||
|
||||
assert.strictEqual(nestedHover.isDisposed, true, 'Nested hover should be disposed when parent is disposed');
|
||||
assertNotInDOM(parentHover, 'Parent hover should be removed from DOM');
|
||||
assertNotInDOM(nestedHover, 'Nested hover should be removed from DOM when parent is disposed');
|
||||
});
|
||||
|
||||
test('should dispose entire hover chain when root is disposed', () => {
|
||||
const hovers = createHoverChain(3);
|
||||
assert.strictEqual(hovers.length, 3, 'Should create 3 hovers');
|
||||
|
||||
// Verify all hovers are in DOM
|
||||
for (let i = 0; i < hovers.length; i++) {
|
||||
assert.ok(mainWindow.document.body.contains(hovers[i].domNode), `Hover ${i + 1} should be in DOM`);
|
||||
}
|
||||
|
||||
// Dispose the root hover
|
||||
hovers[0].dispose();
|
||||
|
||||
// All hovers in the chain should be disposed and removed from DOM
|
||||
for (let i = 0; i < hovers.length; i++) {
|
||||
assert.strictEqual(hovers[i].isDisposed, true, `Hover ${i + 1} should be disposed`);
|
||||
assert.ok(!mainWindow.document.body.contains(hovers[i].domNode), `Hover ${i + 1} should be removed from DOM`);
|
||||
}
|
||||
});
|
||||
|
||||
test('should dispose only nested hovers when middle hover is disposed', () => {
|
||||
const hovers = createHoverChain(3);
|
||||
assert.strictEqual(hovers.length, 3, 'Should create 3 hovers');
|
||||
|
||||
// Verify all hovers are in DOM
|
||||
for (const h of hovers) {
|
||||
assert.ok(mainWindow.document.body.contains(h.domNode), 'All hovers should be in DOM initially');
|
||||
}
|
||||
|
||||
// Dispose the middle hover
|
||||
hovers[1].dispose();
|
||||
|
||||
assert.strictEqual(hovers[0].isDisposed, false, 'Root hover should remain');
|
||||
assert.ok(mainWindow.document.body.contains(hovers[0].domNode), 'Root hover should remain in DOM');
|
||||
|
||||
assert.strictEqual(hovers[1].isDisposed, true, 'Middle hover should be disposed');
|
||||
assert.ok(!mainWindow.document.body.contains(hovers[1].domNode), 'Middle hover should be removed from DOM');
|
||||
|
||||
assert.strictEqual(hovers[2].isDisposed, true, 'Innermost hover should be disposed');
|
||||
assert.ok(!mainWindow.document.body.contains(hovers[2].domNode), 'Innermost hover should be removed from DOM');
|
||||
|
||||
hovers[0].dispose();
|
||||
});
|
||||
|
||||
test('should enforce maximum nesting depth', () => {
|
||||
// Create hovers up to the max depth (3)
|
||||
const hovers = createHoverChain(3);
|
||||
assert.strictEqual(hovers.length, 3, 'Should create exactly 3 hovers (max depth)');
|
||||
|
||||
// Verify all 3 hovers are in DOM
|
||||
for (const h of hovers) {
|
||||
assert.ok(mainWindow.document.body.contains(h.domNode), 'Hover should be in DOM');
|
||||
}
|
||||
|
||||
// Try to create a 4th nested hover - should fail
|
||||
const nestedTarget = document.createElement('div');
|
||||
hovers[2].domNode.appendChild(nestedTarget);
|
||||
const fourthHover = hoverService.showInstantHover({
|
||||
content: 'Hover 4',
|
||||
target: nestedTarget
|
||||
});
|
||||
|
||||
assert.strictEqual(fourthHover, undefined, 'Fourth hover should not be created due to max nesting depth');
|
||||
|
||||
disposeHovers(hovers);
|
||||
});
|
||||
|
||||
test('should allow new hover chain after disposing previous chain', () => {
|
||||
// Create and dispose a chain
|
||||
const firstChain = createHoverChain(3);
|
||||
for (const h of firstChain) {
|
||||
assert.ok(mainWindow.document.body.contains(h.domNode), 'First chain hover should be in DOM');
|
||||
}
|
||||
disposeHovers(firstChain);
|
||||
for (const h of firstChain) {
|
||||
assert.ok(!mainWindow.document.body.contains(h.domNode), 'First chain hover should be removed from DOM');
|
||||
}
|
||||
|
||||
// Should be able to create a new chain
|
||||
const secondChain = createHoverChain(3);
|
||||
assert.strictEqual(secondChain.length, 3, 'Should create new chain after disposing previous');
|
||||
for (const h of secondChain) {
|
||||
assert.ok(mainWindow.document.body.contains(h.domNode), 'Second chain hover should be in DOM');
|
||||
}
|
||||
|
||||
disposeHovers(secondChain);
|
||||
});
|
||||
|
||||
test('hideHover should close innermost hover first', () => {
|
||||
const hovers = createHoverChain(2);
|
||||
|
||||
// Verify both are in DOM
|
||||
assert.ok(mainWindow.document.body.contains(hovers[0].domNode), 'Outer hover should be in DOM');
|
||||
assert.ok(mainWindow.document.body.contains(hovers[1].domNode), 'Inner hover should be in DOM');
|
||||
|
||||
hoverService.hideHover();
|
||||
|
||||
// Innermost hover should be disposed and removed from DOM
|
||||
assert.strictEqual(hovers[1].isDisposed, true, 'Innermost hover should be disposed');
|
||||
assert.ok(!mainWindow.document.body.contains(hovers[1].domNode), 'Innermost hover should be removed from DOM');
|
||||
assert.strictEqual(hovers[0].isDisposed, false, 'Outer hover should remain');
|
||||
assert.ok(mainWindow.document.body.contains(hovers[0].domNode), 'Outer hover should remain in DOM');
|
||||
|
||||
hoverService.hideHover();
|
||||
|
||||
assert.strictEqual(hovers[0].isDisposed, true, 'Outer hover should be disposed on second call');
|
||||
assert.ok(!mainWindow.document.body.contains(hovers[0].domNode), 'Outer hover should be removed from DOM');
|
||||
});
|
||||
});
|
||||
|
||||
suite('setupDelayedHover', () => {
|
||||
test('should evaluate function options on mouseover', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const target = createTarget();
|
||||
let callCount = 0;
|
||||
|
||||
const disposable = hoverService.setupDelayedHover(target, () => {
|
||||
callCount++;
|
||||
return { content: `Call ${callCount}` };
|
||||
});
|
||||
|
||||
// First mouseover
|
||||
target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||
assert.strictEqual(callCount, 1, 'Options function should be called on first mouseover');
|
||||
|
||||
await timeout(0);
|
||||
hoverService.hideHover(true);
|
||||
|
||||
// Second mouseover should call function again
|
||||
target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||
assert.strictEqual(callCount, 2, 'Options function should be called on second mouseover');
|
||||
|
||||
await timeout(0);
|
||||
disposable.dispose();
|
||||
hoverService.hideHover(true);
|
||||
}));
|
||||
});
|
||||
|
||||
suite('setupManagedHover', () => {
|
||||
test('should use native title attribute when showNativeHover is true', () => {
|
||||
const target = createTarget();
|
||||
const hover = hoverService.setupManagedHover(
|
||||
{ showHover: () => undefined, delay: 0, showNativeHover: true },
|
||||
target,
|
||||
'Native hover content'
|
||||
);
|
||||
|
||||
assert.strictEqual(target.getAttribute('title'), 'Native hover content');
|
||||
|
||||
hover.dispose();
|
||||
|
||||
assert.strictEqual(target.getAttribute('title'), null, 'Title should be removed on dispose');
|
||||
});
|
||||
|
||||
test('should update content dynamically', async () => {
|
||||
const target = createTarget();
|
||||
const hover = hoverService.setupManagedHover(
|
||||
{ showHover: () => undefined, delay: 0, showNativeHover: true },
|
||||
target,
|
||||
'Initial'
|
||||
);
|
||||
|
||||
assert.strictEqual(target.getAttribute('title'), 'Initial');
|
||||
|
||||
await hover.update('Updated');
|
||||
assert.strictEqual(target.getAttribute('title'), 'Updated');
|
||||
|
||||
await hover.update('Final');
|
||||
assert.strictEqual(target.getAttribute('title'), 'Final');
|
||||
|
||||
hover.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
suite('showDelayedHover', () => {
|
||||
test('should reject hover when current hover is locked and target is outside', () => {
|
||||
const lockedHover = showHover('Locked', undefined, {
|
||||
persistence: { sticky: true }
|
||||
});
|
||||
assertInDOM(lockedHover, 'Locked hover should be in DOM');
|
||||
|
||||
const otherTarget = createTarget();
|
||||
const rejectedHover = hoverService.showDelayedHover({
|
||||
content: 'Should not show',
|
||||
target: otherTarget
|
||||
}, {});
|
||||
|
||||
assert.strictEqual(rejectedHover, undefined, 'Should reject hover when locked hover exists');
|
||||
assertInDOM(lockedHover, 'Locked hover should remain in DOM after rejection');
|
||||
|
||||
lockedHover.dispose();
|
||||
assertNotInDOM(lockedHover, 'Locked hover should be removed from DOM after dispose');
|
||||
});
|
||||
});
|
||||
|
||||
suite('hover locking', () => {
|
||||
test('isLocked should be settable on hover widget', () => {
|
||||
const hover = showHover('Test');
|
||||
const widget = asHoverWidget(hover);
|
||||
assertInDOM(hover, 'Hover should be in DOM');
|
||||
|
||||
assert.strictEqual(widget.isLocked, false, 'Should not be locked initially');
|
||||
|
||||
widget.isLocked = true;
|
||||
assert.strictEqual(widget.isLocked, true, 'Should be locked after setting');
|
||||
assertInDOM(hover, 'Hover should remain in DOM after locking');
|
||||
|
||||
widget.isLocked = false;
|
||||
assert.strictEqual(widget.isLocked, false, 'Should be unlocked after unsetting');
|
||||
|
||||
hover.dispose();
|
||||
assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');
|
||||
});
|
||||
|
||||
test('sticky option should set isLocked to true', () => {
|
||||
const hover = showHover('Test', undefined, {
|
||||
persistence: { sticky: true }
|
||||
});
|
||||
assertInDOM(hover, 'Sticky hover should be in DOM');
|
||||
|
||||
assert.strictEqual(asHoverWidget(hover).isLocked, true, 'Should be locked when sticky');
|
||||
|
||||
hover.dispose();
|
||||
assertNotInDOM(hover, 'Sticky hover should be removed from DOM after dispose');
|
||||
});
|
||||
});
|
||||
|
||||
suite('showAndFocusLastHover', () => {
|
||||
test('should recreate last disposed hover', () => {
|
||||
const target = createTarget();
|
||||
const hover = hoverService.showInstantHover({
|
||||
content: 'Remember me',
|
||||
target
|
||||
});
|
||||
assert.ok(hover);
|
||||
assertInDOM(hover, 'Initial hover should be in DOM');
|
||||
|
||||
hover.dispose();
|
||||
assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');
|
||||
|
||||
// Should recreate the hover - verify a new hover is shown
|
||||
hoverService.showAndFocusLastHover();
|
||||
|
||||
// Verify there is a hover in the DOM (it's a new hover instance)
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const hoverElements = mainWindow.document.querySelectorAll('.monaco-hover');
|
||||
assert.ok(hoverElements.length > 0, 'A hover should be recreated and in the DOM');
|
||||
|
||||
// Clean up
|
||||
hoverService.hideHover(true);
|
||||
|
||||
// Verify cleanup
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const remainingHovers = mainWindow.document.querySelectorAll('.monaco-hover');
|
||||
assert.strictEqual(remainingHovers.length, 0, 'No hovers should remain in DOM after cleanup');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user