monaco-editor
Version:
A browser based code editor
1,122 lines (1,121 loc) • 70.5 kB
JavaScript
import { setVisibility, clearNode, addDisposableListener, EventType, isHTMLElement, append, $, EventHelper, isActiveElement, isMouseEvent, getWindow, getActiveElement } from '../../../base/browser/dom.js';
import { renderMarkdown } from '../../../base/browser/markdownRenderer.js';
import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js';
import { getAnchorRect } from '../../../base/browser/ui/contextview/contextview.js';
import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js';
import { List } from '../../../base/browser/ui/list/listWidget.js';
import { toAction, SubmenuAction } from '../../../base/common/actions.js';
import { CancellationTokenSource } from '../../../base/common/cancellation.js';
import { Codicon } from '../../../base/common/codicons.js';
import { Emitter } from '../../../base/common/event.js';
import { isMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js';
import { DisposableStore, Disposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { OS } from '../../../base/common/platform.js';
import { ThemeIcon } from '../../../base/common/themables.js';
import { URI } from '../../../base/common/uri.js';
import './actionWidget.css';
import { localize } from '../../../nls.js';
import { IContextViewService } from '../../contextview/browser/contextView.js';
import { IKeybindingService } from '../../keybinding/common/keybinding.js';
import { IOpenerService } from '../../opener/common/opener.js';
import { defaultListStyles } from '../../theme/browser/defaultStyles.js';
import { asCssVariable } from '../../theme/common/colorUtils.js';
import '../../theme/common/colors/baseColors.js';
import '../../theme/common/colors/chartsColors.js';
import '../../theme/common/colors/editorColors.js';
import '../../theme/common/colors/inputColors.js';
import '../../theme/common/colors/listColors.js';
import '../../theme/common/colors/menuColors.js';
import '../../theme/common/colors/minimapColors.js';
import '../../theme/common/colors/miscColors.js';
import '../../theme/common/colors/quickpickColors.js';
import '../../theme/common/colors/searchColors.js';
import { ILayoutService } from '../../layout/browser/layoutService.js';
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __param = (undefined && undefined.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var ActionListWidget_1;
const acceptSelectedActionCommand = 'acceptSelectedCodeAction';
const previewSelectedActionCommand = 'previewSelectedCodeAction';
class HeaderRenderer {
get templateId() { return "header" /* ActionListItemKind.Header */; }
renderTemplate(container) {
container.classList.add('group-header');
const text = document.createElement('span');
container.append(text);
return { container, text };
}
renderElement(element, _index, templateData) {
templateData.text.textContent = element.group?.title ?? element.label ?? '';
}
disposeTemplate(_templateData) {
// noop
}
}
class SeparatorRenderer {
get templateId() { return "separator" /* ActionListItemKind.Separator */; }
renderTemplate(container) {
container.classList.add('separator');
const text = document.createElement('span');
container.append(text);
return { container, text };
}
renderElement(element, _index, templateData) {
templateData.text.textContent = element.label ?? '';
}
disposeTemplate(_templateData) {
// noop
}
}
let ActionItemRenderer = class ActionItemRenderer {
get templateId() { return "action" /* ActionListItemKind.Action */; }
constructor(_supportsPreview, _onRemoveItem, _onShowSubmenu, _hasAnySubmenuActions, _groupTitleByIndex, _linkHandler, _hideDefaultKeybindingTooltip, _keybindingService, _openerService) {
this._supportsPreview = _supportsPreview;
this._onRemoveItem = _onRemoveItem;
this._onShowSubmenu = _onShowSubmenu;
this._hasAnySubmenuActions = _hasAnySubmenuActions;
this._groupTitleByIndex = _groupTitleByIndex;
this._linkHandler = _linkHandler;
this._hideDefaultKeybindingTooltip = _hideDefaultKeybindingTooltip;
this._keybindingService = _keybindingService;
this._openerService = _openerService;
}
renderTemplate(container) {
container.classList.add(this.templateId);
const icon = document.createElement('div');
icon.className = 'icon';
container.append(icon);
const text = document.createElement('span');
text.className = 'title';
container.append(text);
const badge = document.createElement('span');
badge.className = 'action-item-badge';
container.append(badge);
const description = document.createElement('span');
description.className = 'description';
container.append(description);
const groupTitle = document.createElement('span');
groupTitle.className = 'group-title';
container.append(groupTitle);
const detail = document.createElement('span');
detail.className = 'detail';
container.append(detail);
const keybinding = new KeybindingLabel(container, OS);
const toolbar = document.createElement('div');
toolbar.className = 'action-list-item-toolbar';
container.append(toolbar);
const submenuIndicator = document.createElement('div');
submenuIndicator.className = 'action-list-submenu-indicator';
container.append(submenuIndicator);
const elementDisposables = new DisposableStore();
return { container, icon, text, detail, badge, description, groupTitle, keybinding, toolbar, submenuIndicator, elementDisposables };
}
renderElement(element, _index, data) {
// Clear previous element disposables
data.elementDisposables.clear();
if (element.group?.icon) {
data.icon.className = ThemeIcon.asClassName(element.group.icon);
if (element.group.icon.color) {
data.icon.style.color = asCssVariable(element.group.icon.color.id);
}
}
else {
data.icon.className = ThemeIcon.asClassName(Codicon.lightBulb);
data.icon.style.color = 'var(--vscode-editorLightBulb-foreground)';
}
if (!element.item || !element.label) {
return;
}
setVisibility(!element.hideIcon, data.icon);
// Set aria-expanded for section toggle items
if (element.isSectionToggle) {
const expanded = element.group?.icon === Codicon.chevronDown;
data.container.setAttribute('aria-expanded', String(expanded));
}
else {
data.container.removeAttribute('aria-expanded');
}
// Apply optional className - clean up previous to avoid stale classes
// from virtualized row reuse
if (data.previousClassName) {
data.container.classList.remove(data.previousClassName);
}
data.container.classList.toggle('action-list-custom', !!element.className);
if (element.className) {
data.container.classList.add(element.className);
}
data.previousClassName = element.className;
data.text.textContent = stripNewlines(element.label);
// Render optional badge
if (element.badge) {
data.badge.textContent = element.badge;
data.badge.style.display = '';
}
else {
data.badge.textContent = '';
data.badge.style.display = 'none';
}
if (element.keybinding) {
data.description.textContent = element.keybinding.getLabel();
data.description.style.display = 'inline';
data.description.style.letterSpacing = '0.5px';
}
else if (element.description) {
clearNode(data.description);
if (typeof element.description === 'string') {
data.description.textContent = stripNewlines(element.description);
}
else {
const rendered = renderMarkdown(element.description, {
actionHandler: (content) => {
const uri = URI.parse(content);
if (this._linkHandler) {
this._linkHandler(uri, element);
}
else {
void this._openerService.open(uri, { allowCommands: true });
}
}
});
data.elementDisposables.add(rendered);
data.description.appendChild(rendered.element);
}
data.description.style.display = 'inline';
}
else {
data.description.textContent = '';
data.description.style.display = 'none';
}
// Render group title (shown to the right, separate from description)
const groupTitleText = this._groupTitleByIndex.get(_index);
if (groupTitleText) {
data.groupTitle.textContent = groupTitleText;
data.groupTitle.style.display = '';
}
else {
data.groupTitle.textContent = '';
data.groupTitle.style.display = 'none';
}
// Render optional detail (shown as second line below the label)
if (element.detail) {
data.detail.textContent = stripNewlines(element.detail);
data.detail.style.display = '';
}
else {
data.detail.textContent = '';
data.detail.style.display = 'none';
}
const actionTitle = this._keybindingService.lookupKeybinding(acceptSelectedActionCommand)?.getLabel();
const previewTitle = this._keybindingService.lookupKeybinding(previewSelectedActionCommand)?.getLabel();
data.container.classList.toggle('option-disabled', !!element.disabled);
if (element.hover !== undefined) {
// Don't show tooltip when hover content is configured - the rich hover will show instead
data.container.title = '';
}
else if (element.tooltip) {
data.container.title = element.tooltip;
}
else if (element.disabled) {
data.container.title = element.label;
}
else if (this._hideDefaultKeybindingTooltip) {
data.container.title = '';
}
else if (actionTitle && previewTitle) {
if (this._supportsPreview && element.canPreview) {
data.container.title = localize(1705, "{0} to Apply, {1} to Preview", actionTitle, previewTitle);
}
else {
data.container.title = localize(1706, "{0} to Apply", actionTitle);
}
}
else {
data.container.title = '';
}
// Clear and render toolbar actions
clearNode(data.toolbar);
const toolbarActions = [...(element.toolbarActions ?? [])];
if (element.onRemove) {
toolbarActions.push(toAction({
id: 'actionList.remove',
label: localize(1707, "Remove"),
class: ThemeIcon.asClassName(Codicon.close),
run: async () => {
await element.onRemove();
this._onRemoveItem?.(element);
},
}));
}
data.container.classList.toggle('has-toolbar', toolbarActions.length > 0);
if (toolbarActions.length > 0) {
const actionBar = new ActionBar(data.toolbar);
data.elementDisposables.add(actionBar);
actionBar.push(toolbarActions, { icon: true, label: false });
}
// Show submenu indicator only for items with submenu actions
// but not when the item also has hover content (panel auto-shows on hover)
if (element.submenuActions?.length && !element.hover?.content) {
data.submenuIndicator.className = 'action-list-submenu-indicator has-submenu ' + ThemeIcon.asClassName(Codicon.chevronRight);
data.submenuIndicator.style.display = '';
data.submenuIndicator.style.visibility = '';
data.elementDisposables.add(addDisposableListener(data.submenuIndicator, EventType.CLICK, (e) => {
e.stopPropagation();
this._onShowSubmenu?.(element);
}));
}
else if (this._hasAnySubmenuActions) {
// Reserve space for alignment when other items have submenus
data.submenuIndicator.className = 'action-list-submenu-indicator';
data.submenuIndicator.style.display = '';
data.submenuIndicator.style.visibility = 'hidden';
}
else {
data.submenuIndicator.className = 'action-list-submenu-indicator';
data.submenuIndicator.style.display = 'none';
}
}
disposeTemplate(templateData) {
templateData.keybinding.dispose();
templateData.elementDisposables.dispose();
}
};
ActionItemRenderer = __decorate([
__param(7, IKeybindingService),
__param(8, IOpenerService)
], ActionItemRenderer);
class AcceptSelectedEvent extends UIEvent {
constructor() { super('acceptSelectedAction'); }
}
class PreviewSelectedEvent extends UIEvent {
constructor() { super('previewSelectedAction'); }
}
function getKeyboardNavigationLabel(item) {
// Filter out header vs. action vs. separator
if (item.kind === 'action') {
return item.label;
}
return undefined;
}
/**
* A standalone action list widget that handles core list rendering, filtering,
* hover, submenu, and section management without depending on IContextViewService
* or anchor-based positioning. Suitable for embedding directly in any container.
*/
let ActionListWidget = ActionListWidget_1 = class ActionListWidget extends Disposable {
constructor(user, _supportsPreview, items, _delegate, accessibilityProvider, _options, _keybindingService, _openerService, _instantiationService) {
super();
this._supportsPreview = _supportsPreview;
this._delegate = _delegate;
this._options = _options;
this._keybindingService = _keybindingService;
this._openerService = _openerService;
this._instantiationService = _instantiationService;
this._headerLineHeight = 24;
this._separatorLineHeight = 8;
this.cts = this._register(new CancellationTokenSource());
this._submenuDisposables = this._register(new DisposableStore());
this._collapsedSections = new Set();
this._filterText = '';
this._suppressHover = false;
this._hasLaidOut = false;
this._filterCts = this._register(new MutableDisposable());
this._groupTitleByIndex = new Map();
this._onDidRequestLayout = this._register(new Emitter());
/**
* Fired when the widget's visible item set changes and the parent should
* re-layout (e.g. after filtering or collapsing a section).
*/
this.onDidRequestLayout = this._onDidRequestLayout.event;
this.domNode = document.createElement('div');
this.domNode.classList.add('actionList');
if (this._options?.inlineDescription) {
this.domNode.classList.add('inline-description');
}
if (this._options?.className) {
const classNames = this._options.className.split(/\s+/).filter(className => className.length > 0);
if (classNames.length > 0) {
this.domNode.classList.add(...classNames);
}
}
this._actionLineHeight = 24;
// Create submenu container appended to domNode
this._submenuContainer = document.createElement('div');
this._submenuContainer.className = 'action-list-submenu-panel action-widget';
this._submenuContainer.style.display = 'none';
// Make focusable so clicking the hover panel keeps focus inside the
// tracked element instead of moving it to document.body (which would
// trigger the blur handler and dismiss the widget).
this._submenuContainer.tabIndex = -1;
this.domNode.append(this._submenuContainer);
this._register(addDisposableListener(this._submenuContainer, 'mouseenter', () => {
this._cancelSubmenuHide();
}));
this._register(addDisposableListener(this._submenuContainer, 'mouseleave', () => {
this._scheduleSubmenuHide();
}));
this._register(toDisposable(() => {
this._cancelSubmenuHide();
this._cancelSubmenuShow();
}));
// Initialize collapsed sections
if (this._options?.collapsedByDefault) {
for (const section of this._options.collapsedByDefault) {
this._collapsedSections.add(section);
}
}
const virtualDelegate = {
getHeight: element => {
return this._getItemHeight(element);
},
getTemplateId: element => element.kind
};
const reserveSubmenuSpace = this._options?.reserveSubmenuSpace ?? true;
const hasAnySubmenuActions = reserveSubmenuSpace && items.some(item => !!item.submenuActions?.length && !item.hover?.content);
this._list = this._register(new List(user, this.domNode, virtualDelegate, [
new ActionItemRenderer(this._supportsPreview, (item) => this._removeItem(item), (item) => this._showSubmenuForItem(item), hasAnySubmenuActions, this._groupTitleByIndex, this._options?.linkHandler, this._options?.hideDefaultKeybindingTooltip ?? false, this._keybindingService, this._openerService),
new HeaderRenderer(),
new SeparatorRenderer(),
], {
keyboardSupport: false,
typeNavigationEnabled: !this._options?.showFilter,
keyboardNavigationLabelProvider: { getKeyboardNavigationLabel },
accessibilityProvider: {
getAriaLabel: element => {
if (element.kind === "action" /* ActionListItemKind.Action */) {
let label = element.label ? stripNewlines(element?.label) : '';
if (element.detail) {
label = label + ', ' + stripNewlines(element.detail);
}
if (element.ariaDescription) {
label = label + ', ' + stripNewlines(element.ariaDescription);
}
else if (element.description) {
const descText = typeof element.description === 'string' ? element.description : element.description.value;
label = label + ', ' + stripNewlines(descText);
}
if (element.hover?.content && !element.ariaDescription && !element.description) {
const hoverContent = element.hover.content;
const hoverText = typeof hoverContent === 'string' ? hoverContent : isMarkdownString(hoverContent) ? hoverContent.value : isHTMLElement(hoverContent) ? hoverContent.textContent ?? undefined : undefined;
if (hoverText && (!element.detail || stripNewlines(element.detail) !== stripNewlines(hoverText))) {
label = label + ', ' + stripNewlines(hoverText);
}
}
if (element.group?.title) {
label = label + ', ' + element.group.title;
}
if (element.disabled) {
label = localize(1708, "{0}, Disabled Reason: {1}", label, element.disabled);
}
if (element.submenuActions?.length) {
label = localize(1709, "{0}, use right arrow to access options", label);
}
return label;
}
return null;
},
getWidgetAriaLabel: () => localize(1710, "Action Widget"),
getRole: (e) => {
switch (e.kind) {
case "action" /* ActionListItemKind.Action */:
return 'option';
case "separator" /* ActionListItemKind.Separator */:
return 'separator';
default:
return 'separator';
}
},
getWidgetRole: () => 'listbox',
...accessibilityProvider
},
}));
this._list.style(defaultListStyles);
this._register(this._list.onMouseClick(e => this.onListClick(e)));
this._register(this._list.onMouseOver(e => this.onListHover(e)));
this._register(this._list.onDidChangeFocus(() => this.onFocus()));
this._register(this._list.onDidChangeSelection(e => this.onListSelection(e)));
this._allMenuItems = [...items];
// Create filter input and/or secondary heading
if (this._options?.showFilter || this._options?.secondaryHeading) {
this._filterContainer = document.createElement('div');
this._filterContainer.className = 'action-list-filter';
const filterRow = append(this._filterContainer, $('.action-list-filter-row'));
if (this._options?.showFilter) {
this._filterInput = document.createElement('input');
this._filterInput.type = 'text';
this._filterInput.className = 'action-list-filter-input';
this._filterInput.placeholder = this._options?.filterPlaceholder ?? localize(1711, "Search...");
this._filterInput.setAttribute('aria-label', localize(1712, "Filter items"));
filterRow.appendChild(this._filterInput);
const filterActions = this._options?.filterActions ?? [];
if (filterActions.length > 0) {
const filterActionsContainer = append(filterRow, $('.action-list-filter-actions'));
const filterActionBar = this._register(new ActionBar(filterActionsContainer));
filterActionBar.push(filterActions, { icon: true, label: false });
}
this._register(addDisposableListener(this._filterInput, 'input', () => {
this._filterText = this._filterInput.value;
this._applyOrUpdateFilter();
}));
}
if (this._options?.secondaryHeading) {
const filterLabelEl = append(filterRow, $('.action-list-filter-label'));
filterLabelEl.textContent = this._options.secondaryHeading;
}
}
// Create footer text
if (this._options?.footerText) {
this._footerContainer = document.createElement('div');
this._footerContainer.className = 'action-list-footer';
this._footerContainer.textContent = this._options.footerText;
}
// Create header banner
if (this._options?.headerText) {
this._headerContainer = document.createElement('div');
this._headerContainer.className = 'action-list-header';
if (this._options.headerIcon) {
const icon = append(this._headerContainer, $('span.action-list-header-icon'));
icon.classList.add(...ThemeIcon.asClassNameArray(this._options.headerIcon));
// Decorative: the header text already conveys the meaning.
icon.setAttribute('aria-hidden', 'true');
}
const text = append(this._headerContainer, $('span.action-list-header-text'));
text.textContent = this._options.headerText;
}
this._applyFilter();
if (this._list.length) {
this._focusCheckedOrFirst();
}
// ArrowRight opens submenu for the focused item and moves focus into it
this._register(addDisposableListener(this.domNode, 'keydown', (e) => {
if (e.key === 'ArrowRight') {
const focused = this._list.getFocus();
if (focused.length > 0) {
const element = this._list.element(focused[0]);
if (element?.submenuActions?.length) {
EventHelper.stop(e, true);
const rowElement = this._getRowElement(focused[0]);
if (rowElement) {
this._showSubmenuForElement(element, rowElement);
this._currentSubmenuWidget?.focus();
}
}
}
}
}));
// When the list has focus and user types a printable character,
// forward it to the filter input so search begins automatically.
if (this._filterInput) {
this._register(addDisposableListener(this.domNode, 'keydown', (e) => {
if (this._filterInput && !isActiveElement(this._filterInput)
&& e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
this._filterInput.focus();
this._filterInput.value = e.key;
this._filterText = e.key;
this._applyOrUpdateFilter();
e.preventDefault();
e.stopPropagation();
}
}));
}
}
_toggleSection(section) {
if (this._collapsedSections.has(section)) {
this._collapsedSections.delete(section);
}
else {
this._collapsedSections.add(section);
}
this._options?.onDidToggleSection?.(section, this._collapsedSections.has(section));
this._applyFilter();
}
_applyOrUpdateFilter() {
if (!this._delegate.onFilter) {
this._applyFilter();
return;
}
const filterText = this._filterText;
this._filterCts.value?.cancel();
const cts = new CancellationTokenSource();
this._filterCts.value = cts;
this._delegate.onFilter(filterText, cts.token).then(items => {
if (cts.token.isCancellationRequested) {
return;
}
this._allMenuItems = [...items];
this._applyFilter(true);
}).catch(() => { });
}
_applyFilter(skipTextFilter = false, fireLayout = true) {
const filterLower = skipTextFilter ? '' : this._filterText.toLowerCase();
const isFiltering = !skipTextFilter && filterLower.length > 0;
const visible = [];
// Remember the focused item before splice
const focusedIndexes = this._list.getFocus();
let focusedItem;
if (focusedIndexes.length > 0) {
focusedItem = this._list.element(focusedIndexes[0]);
}
if (isFiltering) {
let pendingSeparator;
let filteredSectionItems = [];
let hasMatchingActionInSection = false;
const flushFilteredSection = () => {
if (pendingSeparator && hasMatchingActionInSection) {
visible.push(pendingSeparator);
}
visible.push(...filteredSectionItems);
pendingSeparator = undefined;
filteredSectionItems = [];
hasMatchingActionInSection = false;
};
const matchesFilter = (item) => {
const label = (item.label ?? '').toLowerCase();
const descValue = typeof item.description === 'string' ? item.description : (item.description?.value ?? '');
return label.includes(filterLower) || descValue.toLowerCase().includes(filterLower);
};
for (const item of this._allMenuItems) {
if (item.kind === "header" /* ActionListItemKind.Header */) {
continue;
}
if (item.kind === "separator" /* ActionListItemKind.Separator */) {
flushFilteredSection();
pendingSeparator = item.label ? item : undefined;
continue;
}
if (item.showAlways) {
filteredSectionItems.push(item);
continue;
}
if (item.isSectionToggle) {
continue;
}
if (matchesFilter(item)) {
hasMatchingActionInSection = true;
filteredSectionItems.push(item);
}
}
flushFilteredSection();
}
else {
for (const item of this._allMenuItems) {
if (item.kind === "header" /* ActionListItemKind.Header */) {
visible.push(item);
continue;
}
if (item.kind === "separator" /* ActionListItemKind.Separator */) {
if (item.section && this._collapsedSections.has(item.section)) {
continue;
}
visible.push(item);
continue;
}
// Update icon for section toggle items based on collapsed state
if (item.isSectionToggle && item.section) {
const collapsed = this._collapsedSections.has(item.section);
visible.push({
...item,
group: { ...item.group, icon: collapsed ? Codicon.chevronRight : Codicon.chevronDown },
});
continue;
}
// Not filtering - check collapsed sections
if (item.section && this._collapsedSections.has(item.section)) {
continue;
}
visible.push(item);
}
}
// Remove orphaned separators while keeping labeled separators that act as
// section headers above their following action items.
const hasActionBefore = [];
let seenAction = false;
for (let i = 0; i < visible.length; i++) {
hasActionBefore[i] = seenAction;
if (visible[i].kind === "action" /* ActionListItemKind.Action */) {
seenAction = true;
}
}
const hasActionBeforeNextSeparator = [];
let seenActionInSection = false;
for (let i = visible.length - 1; i >= 0; i--) {
if (visible[i].kind === "action" /* ActionListItemKind.Action */) {
seenActionInSection = true;
continue;
}
if (visible[i].kind !== "separator" /* ActionListItemKind.Separator */) {
continue;
}
hasActionBeforeNextSeparator[i] = seenActionInSection;
seenActionInSection = false;
}
for (let i = visible.length - 1; i >= 0; i--) {
const item = visible[i];
if (item.kind !== "separator" /* ActionListItemKind.Separator */) {
continue;
}
const hasFollowingActionInSection = hasActionBeforeNextSeparator[i];
const isLeadingUnlabeledDivider = !item.label && !hasActionBefore[i];
if (!hasFollowingActionInSection || isLeadingUnlabeledDivider) {
visible.splice(i, 1);
}
}
// Recompute group title positions based on visible items
if (this._options?.showGroupTitleOnFirstItem) {
this._recomputeGroupTitles(visible);
}
// Capture whether the filter input currently has focus before splice
// which may cause DOM changes that shift focus.
const filterInputHasFocus = this._filterInput && isActiveElement(this._filterInput);
this._list.splice(0, this._list.length, visible);
// Notify the parent that a re-layout is needed
if (fireLayout) {
this._onDidRequestLayout.fire();
}
// Restore focus after splice destroyed DOM elements,
// otherwise the blur handler in ActionWidgetService closes the widget.
// Keep focus on the filter input if the user is typing a filter.
if (filterInputHasFocus) {
this._filterInput?.focus();
// Keep a highlighted item in the list so Enter works without pressing DownArrow first
this._focusCheckedOrFirst();
}
else if (this._hasLaidOut) {
// Restore focus to the previously focused item
if (focusedItem) {
const focusedItemId = focusedItem.item?.id;
if (focusedItemId) {
for (let i = 0; i < this._list.length; i++) {
const el = this._list.element(i);
if (el.item?.id === focusedItemId) {
this._list.setFocus([i]);
this._list.reveal(i);
// Move DOM focus back to the list: the splice above destroyed
// the previously focused row, leaving DOM focus on the body.
this._list.domFocus();
break;
}
}
}
}
}
}
/**
* Returns the filter container element, if filter is enabled.
* The caller is responsible for appending it to the widget DOM.
*/
get filterContainer() {
return this._filterContainer;
}
get footerContainer() {
return this._footerContainer;
}
get headerContainer() {
return this._headerContainer;
}
get filterInput() {
return this._filterInput;
}
focusCondition(element) {
return !element.disabled && element.kind === "action" /* ActionListItemKind.Action */;
}
focus() {
if (this._filterInput && this._options?.focusFilterOnOpen) {
this._filterInput.focus();
// Highlight the first item so Enter works immediately
this._focusCheckedOrFirst();
return;
}
this._list.domFocus();
this._focusCheckedOrFirst();
}
clearFocus() {
this._list.setFocus([]);
}
getFocusedElement() {
const focused = this._list.getFocus();
if (focused.length > 0) {
return this._list.element(focused[0]);
}
return undefined;
}
_focusCheckedOrFirst() {
this._suppressHover = true;
try {
// Try to focus the checked item first
for (let i = 0; i < this._list.length; i++) {
const element = this._list.element(i);
if (element.kind === "action" /* ActionListItemKind.Action */ && element.item?.checked) {
this._list.setFocus([i]);
this._list.reveal(i);
return;
}
}
// Set focus on the first focusable item without moving DOM focus
this._list.focusFirst(undefined, this.focusCondition);
const focused = this._list.getFocus();
if (focused.length > 0) {
this._list.reveal(focused[0]);
}
}
finally {
this._suppressHover = false;
}
}
hide(didCancel) {
this._delegate.onHide(didCancel);
this.cts.cancel();
this._filterCts.value?.cancel();
this._filterCts.clear();
this._hideSubmenu();
}
clearFilter() {
if (this._filterInput && this._filterText) {
this._filterInput.value = '';
this._filterText = '';
this._applyOrUpdateFilter();
return true;
}
return false;
}
/**
* Whether this widget uses dynamic height (has filter or collapsible sections).
*/
get hasDynamicHeight() {
if (this._options?.showFilter) {
return true;
}
return this._allMenuItems.some(item => item.isSectionToggle);
}
/**
* The height of a single action row in pixels.
*/
get lineHeight() {
return this._actionLineHeight;
}
/**
* Returns the height for an action item, using a taller line height
* for items with a detail (second line).
*/
_getItemHeight(item) {
switch (item.kind) {
case "header" /* ActionListItemKind.Header */:
return this._headerLineHeight;
case "separator" /* ActionListItemKind.Separator */:
return item.label ? this._actionLineHeight : this._separatorLineHeight;
default:
return item.detail ? (this._options?.detailItemHeight ?? 48) : this._actionLineHeight;
}
}
/**
* Computes the total height of all items (including collapsed/filtered items).
*/
computeFullHeight() {
let fullHeight = 0;
for (const item of this._allMenuItems) {
fullHeight += this._getItemHeight(item);
}
return fullHeight;
}
/**
* Computes the total height of visible items in the list.
*/
computeListHeight() {
const visibleCount = this._list.length;
let listHeight = 0;
for (let i = 0; i < visibleCount; i++) {
const element = this._list.element(i);
listHeight += this._getItemHeight(element);
}
return listHeight;
}
/**
* Lays out the list widget with the given explicit dimensions.
*/
layout(height, width) {
this._hasLaidOut = true;
this._list.layout(height, width);
this.domNode.style.height = `${height}px`;
// Place filter container on the preferred side.
if (this._filterContainer && this._filterContainer.parentElement) {
this._filterContainer.parentElement.insertBefore(this._filterContainer, this.domNode);
}
}
computeMaxWidth(minWidth) {
const visibleCount = this._list.length;
const effectiveMinWidth = Math.max(minWidth, this._options?.minWidth ?? 0);
const rawMaxWidthCap = this._options?.maxWidth ?? Number.POSITIVE_INFINITY;
const maxWidthCap = Math.max(rawMaxWidthCap, effectiveMinWidth);
const clamp = (w) => Math.min(Math.max(w, effectiveMinWidth), maxWidthCap);
let maxWidth = effectiveMinWidth;
const totalItemCount = this._allMenuItems.length;
if (totalItemCount >= 50) {
return clamp(380);
}
if (totalItemCount > visibleCount) {
// Temporarily splice in all items to measure widths,
// preventing width jumps when expanding/collapsing sections.
const visibleItems = [];
for (let i = 0; i < visibleCount; i++) {
visibleItems.push(this._list.element(i));
}
const allItems = [...this._allMenuItems];
this._list.splice(0, visibleCount, allItems);
let allItemsHeight = 0;
for (const item of allItems) {
allItemsHeight += this._getItemHeight(item);
}
this._list.layout(allItemsHeight);
const itemWidths = [];
for (let i = 0; i < allItems.length; i++) {
const element = this._getRowElement(i);
if (element) {
element.style.width = 'auto';
const width = element.getBoundingClientRect().width;
element.style.width = '';
itemWidths.push(width + this._computeToolbarWidth(allItems[i]));
}
}
maxWidth = clamp(Math.max(...itemWidths));
// Restore visible items
this._list.splice(0, allItems.length, visibleItems);
return maxWidth;
}
// All items are visible, measure them directly
const itemWidths = [];
for (let i = 0; i < visibleCount; i++) {
const element = this._getRowElement(i);
if (element) {
element.style.width = 'auto';
const width = element.getBoundingClientRect().width;
element.style.width = '';
itemWidths.push(width + this._computeToolbarWidth(this._list.element(i)));
}
}
return clamp(Math.max(...itemWidths));
}
focusPrevious() {
if (this._filterInput && isActiveElement(this._filterInput)) {
this._list.domFocus();
// An item is already highlighted; advance from it instead of jumping to last
const current = this._list.getFocus();
if (current.length > 0) {
this._list.focusPrevious(1, false, undefined, this.focusCondition);
const focused = this._list.getFocus();
// If we couldn't move (already at first), go to filter
if (focused.length > 0 && focused[0] >= current[0]) {
this._filterInput.focus();
}
else if (focused.length > 0) {
this._list.reveal(focused[0]);
}
}
else {
this._list.focusLast(undefined, this.focusCondition);
const focused = this._list.getFocus();
if (focused.length > 0) {
this._list.reveal(focused[0]);
}
}
return;
}
const previousFocus = this._list.getFocus();
this._list.focusPrevious(1, true, undefined, this.focusCondition);
const focused = this._list.getFocus();
if (focused.length > 0) {
// If focus wrapped (was at first focusable, now at last), move to filter instead
if (this._filterInput && previousFocus.length > 0 && focused[0] > previousFocus[0]) {
this._list.setFocus([]);
this._filterInput.focus();
return;
}
this._list.reveal(focused[0]);
}
}
focusNext() {
if (this._filterInput && isActiveElement(this._filterInput)) {
this._list.domFocus();
// An item is already highlighted; advance from it instead of jumping to first
const current = this._list.getFocus();
if (current.length > 0) {
this._list.focusNext(1, false, undefined, this.focusCondition);
const focused = this._list.getFocus();
if (focused.length > 0) {
this._list.reveal(focused[0]);
}
}
else {
this._list.focusFirst(undefined, this.focusCondition);
const focused = this._list.getFocus();
if (focused.length > 0) {
this._list.reveal(focused[0]);
}
}
return;
}
const previousFocus = this._list.getFocus();
this._list.focusNext(1, true, undefined, this.focusCondition);
const focused = this._list.getFocus();
if (focused.length > 0) {
// If focus wrapped (was at last focusable, now at first), move to filter instead
if (this._filterInput && previousFocus.length > 0 && focused[0] < previousFocus[0]) {
this._list.setFocus([]);
this._filterInput.focus();
return;
}
this._list.reveal(focused[0]);
}
}
collapseFocusedSection() {
const section = this._getFocusedSection();
if (section && !this._collapsedSections.has(section)) {
this._toggleSection(section);
}
}
expandFocusedSection() {
const section = this._getFocusedSection();
if (section && this._collapsedSections.has(section)) {
this._toggleSection(section);
}
}
toggleFocusedSection() {
const focused = this._list.getFocus();
if (focused.length === 0) {
return false;
}
const element = this._list.element(focused[0]);
if (element.isSectionToggle && element.section) {
this._toggleSection(element.section);
return true;
}
return false;
}
_getFocusedSection() {
const focused = this._list.getFocus();
if (focused.length === 0) {
return undefined;
}
const element = this._list.element(focused[0]);
if (element.isSectionToggle && element.section) {
return element.section;
}
return element.section;
}
acceptSelected(preview) {
const focused = this._list.getFocus();
if (focused.length === 0) {
return;
}
const focusIndex = focused[0];
const element = this._list.element(focusIndex);
if (!this.focusCondition(element)) {
return;
}
const event = preview ? new PreviewSelectedEvent() : new AcceptSelectedEvent();
this._list.setSelection([focusIndex], event);
}
onListSelection(e) {
if (!e.elements.length) {
return;
}
const element = e.elements[0];
if (element.isSectionToggle && element.section) {
this._list.setSelection([]);
const section = element.section;
queueMicrotask(() => {
this._toggleSection(section);
});
return;
}
// Don't select when clicking the toolbar or submenu indicator
if (isMouseEvent(e.browserEvent)) {
const target = e.browserEvent.target;
if (isHTMLElement(target) && (target.closest('.action-list-item-toolbar') || target.closest('.action-list-submenu-indicator'))) {
this._list.setSelection([]);
return;
}
}
if (element.item && this.focusCondition(element)) {
const isPreviewEvent = e.browserEvent instanceof PreviewSelectedEvent;
this._delegate.onSelect(element.item, isPreviewEvent && this._supportsPreview);
}
else {
this._list.setSelection([]);
}
}
onFocus() {
const focused = this._list.getFocus();
if (focused.length === 0) {
return;
}
const focusIndex = focused[0];
const element = this._list.element(focusIndex);
this._delegate.onFocus?.(element.item);
// Show hover on focus change (suppress during programmatic initial focus)
if (!this._suppressHover) {
this._showHoverForElement(element, focusIndex);
}
}
_removeItem(item) {
const index = this._allMenuItems.indexOf(item);
if (index >= 0) {
this._allMenuItems.splice(index, 1);
this._applyFilter();
}
}
_recomputeGroupTitles(items) {
this._groupTitleByIndex.clear();
const seenTitles = new Set();
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "action" /* ActionListItemKind.Action */ && item.group?.title && !seenTitles.has(item.group.title)) {
seenTitles.add(item.group.title);
this._groupTitleByIndex.set(i, item.group.title);
}
}
}
_computeToolbarWidth(item) {
let actionCount = item.toolbarActions?.length ?? 0;
if (item.onRemove) {
actionCount++;
}
if (actionCount === 0) {
return 0;
}
// Each toolbar action button is ~22px (16px icon + padding) plus 6px row gap
const actionButtonWidth = 22;
return actionCount * actionButtonWidth + 6;
}
_getRowElement(index) {
// eslint-disable-next-line no-restricted-syntax
return this.domNode.ownerDocument.getElementById(this._list.getElementID(index));
}
_showHoverForElement(element, index) {
if (this._currentSubmenuElement === element) {
return;
}
const hasHoverContent = !!element.hover?.content;
const hasSubmenuActions = !!element.submenuActions?.length;
if (hasHoverContent || hasSubmenuActions) {
const rowElement = this._getRowElement(index);
if (rowElement) {
this._showSubmenuForElement(element, rowElement);
}
return;
}
// Navigated to an item with no hover/submenu — fully tear down any
// previous submenu so a blank panel doesn't linger.
this._hideSubmenu();
}
_showSubmenuForItem(item) {
const index = this._list.indexOf(item);
if (index >= 0) {
const rowElement = this._getRowElement(index);
if (rowElement) {
this._showSubmenuForElement(item, ro