Path: blob/main/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { localize } from '../../../../nls.js';6import * as arrays from '../../../common/arrays.js';7import { Emitter, Event } from '../../../common/event.js';8import { KeyCode, KeyCodeUtils } from '../../../common/keyCodes.js';9import { Disposable, IDisposable } from '../../../common/lifecycle.js';10import { isMacintosh } from '../../../common/platform.js';11import { ScrollbarVisibility } from '../../../common/scrollable.js';12import * as cssJs from '../../cssValue.js';13import * as dom from '../../dom.js';14import * as domStylesheetsJs from '../../domStylesheets.js';15import { DomEmitter } from '../../event.js';16import { StandardKeyboardEvent } from '../../keyboardEvent.js';17import { MarkdownActionHandler, renderMarkdown } from '../../markdownRenderer.js';18import { AnchorPosition, IContextViewProvider } from '../contextview/contextview.js';19import type { IManagedHover } from '../hover/hover.js';20import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js';21import { getDefaultHoverDelegate } from '../hover/hoverDelegateFactory.js';22import { IListEvent, IListRenderer, IListVirtualDelegate } from '../list/list.js';23import { List } from '../list/listWidget.js';24import { ISelectBoxDelegate, ISelectBoxOptions, ISelectBoxStyles, ISelectData, ISelectOptionItem } from './selectBox.js';25import './selectBoxCustom.css';262728const $ = dom.$;2930const SELECT_OPTION_ENTRY_TEMPLATE_ID = 'selectOption.entry.template';3132interface ISelectListTemplateData {33root: HTMLElement;34text: HTMLElement;35detail: HTMLElement;36decoratorRight: HTMLElement;37}3839class SelectListRenderer implements IListRenderer<ISelectOptionItem, ISelectListTemplateData> {4041get templateId(): string { return SELECT_OPTION_ENTRY_TEMPLATE_ID; }4243renderTemplate(container: HTMLElement): ISelectListTemplateData {44const data: ISelectListTemplateData = Object.create(null);45data.root = container;46data.text = dom.append(container, $('.option-text'));47data.detail = dom.append(container, $('.option-detail'));48data.decoratorRight = dom.append(container, $('.option-decorator-right'));4950return data;51}5253renderElement(element: ISelectOptionItem, index: number, templateData: ISelectListTemplateData): void {54const data: ISelectListTemplateData = templateData;5556const text = element.text;57const detail = element.detail;58const decoratorRight = element.decoratorRight;5960const isDisabled = element.isDisabled;6162data.text.textContent = text;63data.detail.textContent = !!detail ? detail : '';64data.decoratorRight.textContent = !!decoratorRight ? decoratorRight : '';6566// pseudo-select disabled option67if (isDisabled) {68data.root.classList.add('option-disabled');69} else {70// Make sure we do class removal from prior template rendering71data.root.classList.remove('option-disabled');72}73}7475disposeTemplate(_templateData: ISelectListTemplateData): void {76// noop77}78}7980export class SelectBoxList extends Disposable implements ISelectBoxDelegate, IListVirtualDelegate<ISelectOptionItem> {8182private static readonly DEFAULT_DROPDOWN_MINIMUM_BOTTOM_MARGIN = 32;83private static readonly DEFAULT_DROPDOWN_MINIMUM_TOP_MARGIN = 2;84private static readonly DEFAULT_MINIMUM_VISIBLE_OPTIONS = 3;8586private _isVisible: boolean;87private selectBoxOptions: ISelectBoxOptions;88private selectElement: HTMLSelectElement;89private container?: HTMLElement;90private options: ISelectOptionItem[] = [];91private selected: number;92private readonly _onDidSelect: Emitter<ISelectData>;93private readonly styles: ISelectBoxStyles;94private listRenderer!: SelectListRenderer;95private contextViewProvider!: IContextViewProvider;96private selectDropDownContainer!: HTMLElement;97private styleElement!: HTMLStyleElement;98private selectList!: List<ISelectOptionItem>;99private selectDropDownListContainer!: HTMLElement;100private widthControlElement!: HTMLElement;101private _currentSelection = 0;102private _dropDownPosition!: AnchorPosition;103private _hasDetails: boolean = false;104private selectionDetailsPane!: HTMLElement;105private _skipLayout: boolean = false;106private _cachedMaxDetailsHeight?: number;107private _hover?: IManagedHover;108109private _sticky: boolean = false; // for dev purposes only110111constructor(options: ISelectOptionItem[], selected: number, contextViewProvider: IContextViewProvider, styles: ISelectBoxStyles, selectBoxOptions?: ISelectBoxOptions) {112113super();114this._isVisible = false;115this.styles = styles;116117this.selectBoxOptions = selectBoxOptions || Object.create(null);118119if (typeof this.selectBoxOptions.minBottomMargin !== 'number') {120this.selectBoxOptions.minBottomMargin = SelectBoxList.DEFAULT_DROPDOWN_MINIMUM_BOTTOM_MARGIN;121} else if (this.selectBoxOptions.minBottomMargin < 0) {122this.selectBoxOptions.minBottomMargin = 0;123}124125this.selectElement = document.createElement('select');126127// Use custom CSS vars for padding calculation128this.selectElement.className = 'monaco-select-box monaco-select-box-dropdown-padding';129130if (typeof this.selectBoxOptions.ariaLabel === 'string') {131this.selectElement.setAttribute('aria-label', this.selectBoxOptions.ariaLabel);132}133134if (typeof this.selectBoxOptions.ariaDescription === 'string') {135this.selectElement.setAttribute('aria-description', this.selectBoxOptions.ariaDescription);136}137138this._onDidSelect = new Emitter<ISelectData>();139this._register(this._onDidSelect);140141this.registerListeners();142this.constructSelectDropDown(contextViewProvider);143144this.selected = selected || 0;145146if (options) {147this.setOptions(options, selected);148}149150this.initStyleSheet();151152}153154private setTitle(title: string): void {155if (!this._hover && title) {156this._hover = this._register(getBaseLayerHoverDelegate().setupManagedHover(getDefaultHoverDelegate('mouse'), this.selectElement, title));157} else if (this._hover) {158this._hover.update(title);159}160}161162// IDelegate - List renderer163164getHeight(): number {165return 22;166}167168getTemplateId(): string {169return SELECT_OPTION_ENTRY_TEMPLATE_ID;170}171172private constructSelectDropDown(contextViewProvider: IContextViewProvider) {173174// SetUp ContextView container to hold select Dropdown175this.contextViewProvider = contextViewProvider;176this.selectDropDownContainer = dom.$('.monaco-select-box-dropdown-container');177// Use custom CSS vars for padding calculation (shared with parent select)178this.selectDropDownContainer.classList.add('monaco-select-box-dropdown-padding');179180// Setup container for select option details181this.selectionDetailsPane = dom.append(this.selectDropDownContainer, $('.select-box-details-pane'));182183// Create span flex box item/div we can measure and control184const widthControlOuterDiv = dom.append(this.selectDropDownContainer, $('.select-box-dropdown-container-width-control'));185const widthControlInnerDiv = dom.append(widthControlOuterDiv, $('.width-control-div'));186this.widthControlElement = document.createElement('span');187this.widthControlElement.className = 'option-text-width-control';188dom.append(widthControlInnerDiv, this.widthControlElement);189190// Always default to below position191this._dropDownPosition = AnchorPosition.BELOW;192193// Inline stylesheet for themes194this.styleElement = domStylesheetsJs.createStyleSheet(this.selectDropDownContainer);195196// Prevent dragging of dropdown #114329197this.selectDropDownContainer.setAttribute('draggable', 'true');198this._register(dom.addDisposableListener(this.selectDropDownContainer, dom.EventType.DRAG_START, (e) => {199dom.EventHelper.stop(e, true);200}));201}202203private registerListeners() {204205// Parent native select keyboard listeners206207this._register(dom.addStandardDisposableListener(this.selectElement, 'change', (e) => {208this.selected = e.target.selectedIndex;209this._onDidSelect.fire({210index: e.target.selectedIndex,211selected: e.target.value212});213if (!!this.options[this.selected] && !!this.options[this.selected].text) {214this.setTitle(this.options[this.selected].text);215}216}));217218// Have to implement both keyboard and mouse controllers to handle disabled options219// Intercept mouse events to override normal select actions on parents220221this._register(dom.addDisposableListener(this.selectElement, dom.EventType.CLICK, (e) => {222dom.EventHelper.stop(e);223224if (this._isVisible) {225this.hideSelectDropDown(true);226} else {227this.showSelectDropDown();228}229}));230231this._register(dom.addDisposableListener(this.selectElement, dom.EventType.MOUSE_DOWN, (e) => {232dom.EventHelper.stop(e);233}));234235// Intercept touch events236// The following implementation is slightly different from the mouse event handlers above.237// Use the following helper variable, otherwise the list flickers.238let listIsVisibleOnTouchStart: boolean;239this._register(dom.addDisposableListener(this.selectElement, 'touchstart', (e) => {240listIsVisibleOnTouchStart = this._isVisible;241}));242this._register(dom.addDisposableListener(this.selectElement, 'touchend', (e) => {243dom.EventHelper.stop(e);244245if (listIsVisibleOnTouchStart) {246this.hideSelectDropDown(true);247} else {248this.showSelectDropDown();249}250}));251252// Intercept keyboard handling253254this._register(dom.addDisposableListener(this.selectElement, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {255const event = new StandardKeyboardEvent(e);256let showDropDown = false;257258// Create and drop down select list on keyboard select259if (isMacintosh) {260if (event.keyCode === KeyCode.DownArrow || event.keyCode === KeyCode.UpArrow || event.keyCode === KeyCode.Space || event.keyCode === KeyCode.Enter) {261showDropDown = true;262}263} else {264if (event.keyCode === KeyCode.DownArrow && event.altKey || event.keyCode === KeyCode.UpArrow && event.altKey || event.keyCode === KeyCode.Space || event.keyCode === KeyCode.Enter) {265showDropDown = true;266}267}268269if (showDropDown) {270this.showSelectDropDown();271dom.EventHelper.stop(e, true);272}273}));274}275276public get onDidSelect(): Event<ISelectData> {277return this._onDidSelect.event;278}279280public setOptions(options: ISelectOptionItem[], selected?: number): void {281if (!arrays.equals(this.options, options)) {282this.options = options;283this.selectElement.options.length = 0;284this._hasDetails = false;285this._cachedMaxDetailsHeight = undefined;286287this.options.forEach((option, index) => {288this.selectElement.add(this.createOption(option.text, index, option.isDisabled));289if (typeof option.description === 'string') {290this._hasDetails = true;291}292});293}294295if (selected !== undefined) {296this.select(selected);297// Set current = selected since this is not necessarily a user exit298this._currentSelection = this.selected;299}300}301302public setEnabled(enable: boolean): void {303this.selectElement.disabled = !enable;304}305306private setOptionsList() {307308// Mirror options in drop-down309// Populate select list for non-native select mode310this.selectList?.splice(0, this.selectList.length, this.options);311}312313public select(index: number): void {314315if (index >= 0 && index < this.options.length) {316this.selected = index;317} else if (index > this.options.length - 1) {318// Adjust index to end of list319// This could make client out of sync with the select320this.select(this.options.length - 1);321} else if (this.selected < 0) {322this.selected = 0;323}324325this.selectElement.selectedIndex = this.selected;326if (!!this.options[this.selected] && !!this.options[this.selected].text) {327this.setTitle(this.options[this.selected].text);328}329}330331public setAriaLabel(label: string): void {332this.selectBoxOptions.ariaLabel = label;333this.selectElement.setAttribute('aria-label', this.selectBoxOptions.ariaLabel);334}335336public focus(): void {337if (this.selectElement) {338this.selectElement.tabIndex = 0;339this.selectElement.focus();340}341}342343public blur(): void {344if (this.selectElement) {345this.selectElement.tabIndex = -1;346this.selectElement.blur();347}348}349350public setFocusable(focusable: boolean): void {351this.selectElement.tabIndex = focusable ? 0 : -1;352}353354public render(container: HTMLElement): void {355this.container = container;356container.classList.add('select-container');357container.appendChild(this.selectElement);358this.styleSelectElement();359}360361private initStyleSheet(): void {362363const content: string[] = [];364365// Style non-native select mode366367if (this.styles.listFocusBackground) {368content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.focused { background-color: ${this.styles.listFocusBackground} !important; }`);369}370371if (this.styles.listFocusForeground) {372content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.focused { color: ${this.styles.listFocusForeground} !important; }`);373}374375if (this.styles.decoratorRightForeground) {376content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.focused) .option-decorator-right { color: ${this.styles.decoratorRightForeground}; }`);377}378379if (this.styles.selectBackground && this.styles.selectBorder && this.styles.selectBorder !== this.styles.selectBackground) {380content.push(`.monaco-select-box-dropdown-container { border: 1px solid ${this.styles.selectBorder} } `);381content.push(`.monaco-select-box-dropdown-container > .select-box-details-pane.border-top { border-top: 1px solid ${this.styles.selectBorder} } `);382content.push(`.monaco-select-box-dropdown-container > .select-box-details-pane.border-bottom { border-bottom: 1px solid ${this.styles.selectBorder} } `);383384}385else if (this.styles.selectListBorder) {386content.push(`.monaco-select-box-dropdown-container > .select-box-details-pane.border-top { border-top: 1px solid ${this.styles.selectListBorder} } `);387content.push(`.monaco-select-box-dropdown-container > .select-box-details-pane.border-bottom { border-bottom: 1px solid ${this.styles.selectListBorder} } `);388}389390// Hover foreground - ignore for disabled options391if (this.styles.listHoverForeground) {392content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.option-disabled):not(.focused):hover { color: ${this.styles.listHoverForeground} !important; }`);393}394395// Hover background - ignore for disabled options396if (this.styles.listHoverBackground) {397content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.option-disabled):not(.focused):hover { background-color: ${this.styles.listHoverBackground} !important; }`);398}399400// Match quick input outline styles - ignore for disabled options401if (this.styles.listFocusOutline) {402content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.focused { outline: 1.6px dotted ${this.styles.listFocusOutline} !important; outline-offset: -1.6px !important; }`);403}404405if (this.styles.listHoverOutline) {406content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.option-disabled):not(.focused):hover { outline: 1.6px dashed ${this.styles.listHoverOutline} !important; outline-offset: -1.6px !important; }`);407}408409// Clear list styles on focus and on hover for disabled options410content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-disabled.focused { background-color: transparent !important; color: inherit !important; outline: none !important; }`);411content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-disabled:hover { background-color: transparent !important; color: inherit !important; outline: none !important; }`);412413this.styleElement.textContent = content.join('\n');414}415416private styleSelectElement(): void {417const background = this.styles.selectBackground ?? '';418const foreground = this.styles.selectForeground ?? '';419const border = this.styles.selectBorder ?? '';420421this.selectElement.style.backgroundColor = background;422this.selectElement.style.color = foreground;423this.selectElement.style.borderColor = border;424}425426private styleList() {427const background = this.styles.selectBackground ?? '';428429const listBackground = cssJs.asCssValueWithDefault(this.styles.selectListBackground, background);430this.selectDropDownListContainer.style.backgroundColor = listBackground;431this.selectionDetailsPane.style.backgroundColor = listBackground;432const optionsBorder = this.styles.focusBorder ?? '';433this.selectDropDownContainer.style.outlineColor = optionsBorder;434this.selectDropDownContainer.style.outlineOffset = '-1px';435436this.selectList.style(this.styles);437}438439private createOption(value: string, index: number, disabled?: boolean): HTMLOptionElement {440const option = document.createElement('option');441option.value = value;442option.text = value;443option.disabled = !!disabled;444445return option;446}447448// ContextView dropdown methods449450private showSelectDropDown() {451this.selectionDetailsPane.textContent = '';452453if (!this.contextViewProvider || this._isVisible) {454return;455}456457// Lazily create and populate list only at open, moved from constructor458this.createSelectList(this.selectDropDownContainer);459this.setOptionsList();460461// This allows us to flip the position based on measurement462// Set drop-down position above/below from required height and margins463// If pre-layout cannot fit at least one option do not show drop-down464465this.contextViewProvider.showContextView({466getAnchor: () => this.selectElement,467render: (container: HTMLElement) => this.renderSelectDropDown(container, true),468layout: () => {469this.layoutSelectDropDown();470},471onHide: () => {472this.selectDropDownContainer.classList.remove('visible');473this.selectElement.classList.remove('synthetic-focus');474},475anchorPosition: this._dropDownPosition476}, this.selectBoxOptions.optionsAsChildren ? this.container : undefined);477478// Hide so we can relay out479this._isVisible = true;480this.hideSelectDropDown(false);481482this.contextViewProvider.showContextView({483getAnchor: () => this.selectElement,484render: (container: HTMLElement) => this.renderSelectDropDown(container),485layout: () => this.layoutSelectDropDown(),486onHide: () => {487this.selectDropDownContainer.classList.remove('visible');488this.selectElement.classList.remove('synthetic-focus');489},490anchorPosition: this._dropDownPosition491}, this.selectBoxOptions.optionsAsChildren ? this.container : undefined);492493// Track initial selection the case user escape, blur494this._currentSelection = this.selected;495this._isVisible = true;496this.selectElement.setAttribute('aria-expanded', 'true');497}498499private hideSelectDropDown(focusSelect: boolean) {500if (!this.contextViewProvider || !this._isVisible) {501return;502}503504this._isVisible = false;505this.selectElement.setAttribute('aria-expanded', 'false');506507if (focusSelect) {508this.selectElement.focus();509}510511this.contextViewProvider.hideContextView();512}513514private renderSelectDropDown(container: HTMLElement, preLayoutPosition?: boolean): IDisposable {515container.appendChild(this.selectDropDownContainer);516517// Pre-Layout allows us to change position518this.layoutSelectDropDown(preLayoutPosition);519520return {521dispose: () => {522// contextView will dispose itself if moving from one View to another523this.selectDropDownContainer.remove(); // remove to take out the CSS rules we add524}525};526}527528// Iterate over detailed descriptions, find max height529private measureMaxDetailsHeight(): number {530let maxDetailsPaneHeight = 0;531this.options.forEach((_option, index) => {532this.updateDetail(index);533534if (this.selectionDetailsPane.offsetHeight > maxDetailsPaneHeight) {535maxDetailsPaneHeight = this.selectionDetailsPane.offsetHeight;536}537});538539return maxDetailsPaneHeight;540}541542private layoutSelectDropDown(preLayoutPosition?: boolean): boolean {543544// Avoid recursion from layout called in onListFocus545if (this._skipLayout) {546return false;547}548549// Layout ContextView drop down select list and container550// Have to manage our vertical overflow, sizing, position below or above551// Position has to be determined and set prior to contextView instantiation552553if (this.selectList) {554555// Make visible to enable measurements556this.selectDropDownContainer.classList.add('visible');557558const window = dom.getWindow(this.selectElement);559const selectPosition = dom.getDomNodePagePosition(this.selectElement);560const styles = dom.getWindow(this.selectElement).getComputedStyle(this.selectElement);561const verticalPadding = parseFloat(styles.getPropertyValue('--dropdown-padding-top')) + parseFloat(styles.getPropertyValue('--dropdown-padding-bottom'));562const maxSelectDropDownHeightBelow = (window.innerHeight - selectPosition.top - selectPosition.height - (this.selectBoxOptions.minBottomMargin || 0));563const maxSelectDropDownHeightAbove = (selectPosition.top - SelectBoxList.DEFAULT_DROPDOWN_MINIMUM_TOP_MARGIN);564565// Determine optimal width - min(longest option), opt(parent select, excluding margins), max(ContextView controlled)566const selectWidth = this.selectElement.offsetWidth;567const selectMinWidth = this.setWidthControlElement(this.widthControlElement);568const selectOptimalWidth = Math.max(selectMinWidth, Math.round(selectWidth)).toString() + 'px';569570this.selectDropDownContainer.style.width = selectOptimalWidth;571572// Get initial list height and determine space above and below573this.selectList.getHTMLElement().style.height = '';574this.selectList.layout();575let listHeight = this.selectList.contentHeight;576577if (this._hasDetails && this._cachedMaxDetailsHeight === undefined) {578this._cachedMaxDetailsHeight = this.measureMaxDetailsHeight();579}580const maxDetailsPaneHeight = this._hasDetails ? this._cachedMaxDetailsHeight! : 0;581582const minRequiredDropDownHeight = listHeight + verticalPadding + maxDetailsPaneHeight;583const maxVisibleOptionsBelow = ((Math.floor((maxSelectDropDownHeightBelow - verticalPadding - maxDetailsPaneHeight) / this.getHeight())));584const maxVisibleOptionsAbove = ((Math.floor((maxSelectDropDownHeightAbove - verticalPadding - maxDetailsPaneHeight) / this.getHeight())));585586// If we are only doing pre-layout check/adjust position only587// Calculate vertical space available, flip up if insufficient588// Use reflected padding on parent select, ContextView style589// properties not available before DOM attachment590591if (preLayoutPosition) {592593// Check if select moved out of viewport , do not open594// If at least one option cannot be shown, don't open the drop-down or hide/remove if open595596if ((selectPosition.top + selectPosition.height) > (window.innerHeight - 22)597|| selectPosition.top < SelectBoxList.DEFAULT_DROPDOWN_MINIMUM_TOP_MARGIN598|| ((maxVisibleOptionsBelow < 1) && (maxVisibleOptionsAbove < 1))) {599// Indicate we cannot open600return false;601}602603// Determine if we have to flip up604// Always show complete list items - never more than Max available vertical height605if (maxVisibleOptionsBelow < SelectBoxList.DEFAULT_MINIMUM_VISIBLE_OPTIONS606&& maxVisibleOptionsAbove > maxVisibleOptionsBelow607&& this.options.length > maxVisibleOptionsBelow608) {609this._dropDownPosition = AnchorPosition.ABOVE;610this.selectDropDownListContainer.remove();611this.selectionDetailsPane.remove();612this.selectDropDownContainer.appendChild(this.selectionDetailsPane);613this.selectDropDownContainer.appendChild(this.selectDropDownListContainer);614615this.selectionDetailsPane.classList.remove('border-top');616this.selectionDetailsPane.classList.add('border-bottom');617618} else {619this._dropDownPosition = AnchorPosition.BELOW;620this.selectDropDownListContainer.remove();621this.selectionDetailsPane.remove();622this.selectDropDownContainer.appendChild(this.selectDropDownListContainer);623this.selectDropDownContainer.appendChild(this.selectionDetailsPane);624625this.selectionDetailsPane.classList.remove('border-bottom');626this.selectionDetailsPane.classList.add('border-top');627}628// Do full layout on showSelectDropDown only629return true;630}631632// Check if select out of viewport or cutting into status bar633if ((selectPosition.top + selectPosition.height) > (window.innerHeight - 22)634|| selectPosition.top < SelectBoxList.DEFAULT_DROPDOWN_MINIMUM_TOP_MARGIN635|| (this._dropDownPosition === AnchorPosition.BELOW && maxVisibleOptionsBelow < 1)636|| (this._dropDownPosition === AnchorPosition.ABOVE && maxVisibleOptionsAbove < 1)) {637// Cannot properly layout, close and hide638this.hideSelectDropDown(true);639return false;640}641642// SetUp list dimensions and layout - account for container padding643// Use position to check above or below available space644if (this._dropDownPosition === AnchorPosition.BELOW) {645if (this._isVisible && maxVisibleOptionsBelow + maxVisibleOptionsAbove < 1) {646// If drop-down is visible, must be doing a DOM re-layout, hide since we don't fit647// Hide drop-down, hide contextview, focus on parent select648this.hideSelectDropDown(true);649return false;650}651652// Adjust list height to max from select bottom to margin (default/minBottomMargin)653if (minRequiredDropDownHeight > maxSelectDropDownHeightBelow) {654listHeight = (maxVisibleOptionsBelow * this.getHeight());655}656} else {657if (minRequiredDropDownHeight > maxSelectDropDownHeightAbove) {658listHeight = (maxVisibleOptionsAbove * this.getHeight());659}660}661662// Set adjusted list height and relayout663this.selectList.layout(listHeight);664this.selectList.domFocus();665666// Finally set focus on selected item667if (this.selectList.length > 0) {668this.selectList.setFocus([this.selected || 0]);669this.selectList.reveal(this.selectList.getFocus()[0] || 0);670}671672if (this._hasDetails) {673// Leave the selectDropDownContainer to size itself according to children (list + details) - #57447674this.selectList.getHTMLElement().style.height = (listHeight + verticalPadding) + 'px';675this.selectDropDownContainer.style.height = '';676} else {677this.selectDropDownContainer.style.height = (listHeight + verticalPadding) + 'px';678}679680this.updateDetail(this.selected);681682this.selectDropDownContainer.style.width = selectOptimalWidth;683684// Maintain focus outline on parent select as well as list container - tabindex for focus685this.selectDropDownListContainer.setAttribute('tabindex', '0');686this.selectElement.classList.add('synthetic-focus');687this.selectDropDownContainer.classList.add('synthetic-focus');688689return true;690} else {691return false;692}693}694695private setWidthControlElement(container: HTMLElement): number {696let elementWidth = 0;697698if (container) {699let longest = 0;700let longestLength = 0;701702this.options.forEach((option, index) => {703const detailLength = !!option.detail ? option.detail.length : 0;704const rightDecoratorLength = !!option.decoratorRight ? option.decoratorRight.length : 0;705706const len = option.text.length + detailLength + rightDecoratorLength;707if (len > longestLength) {708longest = index;709longestLength = len;710}711});712713714container.textContent = this.options[longest].text + (!!this.options[longest].decoratorRight ? (this.options[longest].decoratorRight + ' ') : '');715elementWidth = dom.getTotalWidth(container);716}717718return elementWidth;719}720721private createSelectList(parent: HTMLElement): void {722723// If we have already constructive list on open, skip724if (this.selectList) {725return;726}727728// SetUp container for list729this.selectDropDownListContainer = dom.append(parent, $('.select-box-dropdown-list-container'));730731this.listRenderer = new SelectListRenderer();732733this.selectList = this._register(new List('SelectBoxCustom', this.selectDropDownListContainer, this, [this.listRenderer], {734useShadows: false,735verticalScrollMode: ScrollbarVisibility.Visible,736keyboardSupport: false,737mouseSupport: false,738accessibilityProvider: {739getAriaLabel: element => {740let label = element.text;741if (element.detail) {742label += `. ${element.detail}`;743}744745if (element.decoratorRight) {746label += `. ${element.decoratorRight}`;747}748749if (element.description) {750label += `. ${element.description}`;751}752753return label;754},755getWidgetAriaLabel: () => localize({ key: 'selectBox', comment: ['Behave like native select dropdown element.'] }, "Select Box"),756getRole: () => isMacintosh ? '' : 'option',757getWidgetRole: () => 'listbox'758}759}));760if (this.selectBoxOptions.ariaLabel) {761this.selectList.ariaLabel = this.selectBoxOptions.ariaLabel;762}763764// SetUp list keyboard controller - control navigation, disabled items, focus765const onKeyDown = this._register(new DomEmitter(this.selectDropDownListContainer, 'keydown'));766const onSelectDropDownKeyDown = Event.chain(onKeyDown.event, $ =>767$.filter(() => this.selectList.length > 0)768.map(e => new StandardKeyboardEvent(e))769);770771this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.Enter))(this.onEnter, this));772this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.Tab))(this.onEnter, this)); // Tab should behave the same as enter, #79339773this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.Escape))(this.onEscape, this));774this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.UpArrow))(this.onUpArrow, this));775this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.DownArrow))(this.onDownArrow, this));776this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.PageDown))(this.onPageDown, this));777this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.PageUp))(this.onPageUp, this));778this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.Home))(this.onHome, this));779this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.End))(this.onEnd, this));780this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => (e.keyCode >= KeyCode.Digit0 && e.keyCode <= KeyCode.KeyZ) || (e.keyCode >= KeyCode.Semicolon && e.keyCode <= KeyCode.NumpadDivide)))(this.onCharacter, this));781782// SetUp list mouse controller - control navigation, disabled items, focus783this._register(dom.addDisposableListener(this.selectList.getHTMLElement(), dom.EventType.POINTER_UP, e => this.onPointerUp(e)));784785this._register(this.selectList.onMouseOver(e => typeof e.index !== 'undefined' && this.selectList.setFocus([e.index])));786this._register(this.selectList.onDidChangeFocus(e => this.onListFocus(e)));787788this._register(dom.addDisposableListener(this.selectDropDownContainer, dom.EventType.FOCUS_OUT, e => {789if (!this._isVisible || dom.isAncestor(e.relatedTarget as HTMLElement, this.selectDropDownContainer)) {790return;791}792this.onListBlur();793}));794795this.selectList.getHTMLElement().setAttribute('aria-label', this.selectBoxOptions.ariaLabel || '');796this.selectList.getHTMLElement().setAttribute('aria-expanded', 'true');797798this.styleList();799}800801// List methods802803// List mouse controller - active exit, select option, fire onDidSelect if change, return focus to parent select804// Also takes in touchend events805private onPointerUp(e: PointerEvent): void {806807if (!this.selectList.length) {808return;809}810811dom.EventHelper.stop(e);812813const target = <Element>e.target;814if (!target) {815return;816}817818// Check our mouse event is on an option (not scrollbar)819if (target.classList.contains('slider')) {820return;821}822823const listRowElement = target.closest('.monaco-list-row');824825if (!listRowElement) {826return;827}828const index = Number(listRowElement.getAttribute('data-index'));829const disabled = listRowElement.classList.contains('option-disabled');830831// Ignore mouse selection of disabled options832if (index >= 0 && index < this.options.length && !disabled) {833this.selected = index;834this.select(this.selected);835836this.selectList.setFocus([this.selected]);837this.selectList.reveal(this.selectList.getFocus()[0]);838839// Only fire if selection change840if (this.selected !== this._currentSelection) {841// Set current = selected842this._currentSelection = this.selected;843844this._onDidSelect.fire({845index: this.selectElement.selectedIndex,846selected: this.options[this.selected].text847848});849if (!!this.options[this.selected] && !!this.options[this.selected].text) {850this.setTitle(this.options[this.selected].text);851}852}853854this.hideSelectDropDown(true);855}856}857858// List Exit - passive - implicit no selection change, hide drop-down859private onListBlur(): void {860if (this._sticky) { return; }861if (this.selected !== this._currentSelection) {862// Reset selected to current if no change863this.select(this._currentSelection);864}865866this.hideSelectDropDown(false);867}868869870private renderDescriptionMarkdown(text: string, actionHandler?: MarkdownActionHandler): HTMLElement {871const cleanRenderedMarkdown = (element: Node) => {872for (let i = 0; i < element.childNodes.length; i++) {873const child = <Element>element.childNodes.item(i);874875const tagName = child.tagName && child.tagName.toLowerCase();876if (tagName === 'img') {877child.remove();878} else {879cleanRenderedMarkdown(child);880}881}882};883884const rendered = renderMarkdown({ value: text, supportThemeIcons: true }, { actionHandler });885886rendered.element.classList.add('select-box-description-markdown');887cleanRenderedMarkdown(rendered.element);888889return rendered.element;890}891892// List Focus Change - passive - update details pane with newly focused element's data893private onListFocus(e: IListEvent<ISelectOptionItem>) {894// Skip during initial layout895if (!this._isVisible || !this._hasDetails) {896return;897}898899this.updateDetail(e.indexes[0]);900}901902private updateDetail(selectedIndex: number): void {903this.selectionDetailsPane.textContent = '';904const option = this.options[selectedIndex];905const description = option?.description ?? '';906const descriptionIsMarkdown = option?.descriptionIsMarkdown ?? false;907908if (description) {909if (descriptionIsMarkdown) {910const actionHandler = option.descriptionMarkdownActionHandler;911this.selectionDetailsPane.appendChild(this.renderDescriptionMarkdown(description, actionHandler));912} else {913this.selectionDetailsPane.textContent = description;914}915this.selectionDetailsPane.style.display = 'block';916} else {917this.selectionDetailsPane.style.display = 'none';918}919920// Avoid recursion921this._skipLayout = true;922this.contextViewProvider.layout();923this._skipLayout = false;924}925926// List keyboard controller927928// List exit - active - hide ContextView dropdown, reset selection, return focus to parent select929private onEscape(e: StandardKeyboardEvent): void {930dom.EventHelper.stop(e);931932// Reset selection to value when opened933this.select(this._currentSelection);934this.hideSelectDropDown(true);935}936937// List exit - active - hide ContextView dropdown, return focus to parent select, fire onDidSelect if change938private onEnter(e: StandardKeyboardEvent): void {939dom.EventHelper.stop(e);940941// Only fire if selection change942if (this.selected !== this._currentSelection) {943this._currentSelection = this.selected;944this._onDidSelect.fire({945index: this.selectElement.selectedIndex,946selected: this.options[this.selected].text947});948if (!!this.options[this.selected] && !!this.options[this.selected].text) {949this.setTitle(this.options[this.selected].text);950}951}952953this.hideSelectDropDown(true);954}955956// List navigation - have to handle a disabled option (jump over)957private onDownArrow(e: StandardKeyboardEvent): void {958if (this.selected < this.options.length - 1) {959dom.EventHelper.stop(e, true);960961// Skip disabled options962const nextOptionDisabled = this.options[this.selected + 1].isDisabled;963964if (nextOptionDisabled && this.options.length > this.selected + 2) {965this.selected += 2;966} else if (nextOptionDisabled) {967return;968} else {969this.selected++;970}971972// Set focus/selection - only fire event when closing drop-down or on blur973this.select(this.selected);974this.selectList.setFocus([this.selected]);975this.selectList.reveal(this.selectList.getFocus()[0]);976}977}978979private onUpArrow(e: StandardKeyboardEvent): void {980if (this.selected > 0) {981dom.EventHelper.stop(e, true);982// Skip disabled options983const previousOptionDisabled = this.options[this.selected - 1].isDisabled;984if (previousOptionDisabled && this.selected > 1) {985this.selected -= 2;986} else {987this.selected--;988}989// Set focus/selection - only fire event when closing drop-down or on blur990this.select(this.selected);991this.selectList.setFocus([this.selected]);992this.selectList.reveal(this.selectList.getFocus()[0]);993}994}995996private onPageUp(e: StandardKeyboardEvent): void {997dom.EventHelper.stop(e);998999this.selectList.focusPreviousPage();10001001// Allow scrolling to settle1002setTimeout(() => {1003this.selected = this.selectList.getFocus()[0];10041005// Shift selection down if we land on a disabled option1006if (this.options[this.selected].isDisabled && this.selected < this.options.length - 1) {1007this.selected++;1008this.selectList.setFocus([this.selected]);1009}1010this.selectList.reveal(this.selected);1011this.select(this.selected);1012}, 1);1013}10141015private onPageDown(e: StandardKeyboardEvent): void {1016dom.EventHelper.stop(e);10171018this.selectList.focusNextPage();10191020// Allow scrolling to settle1021setTimeout(() => {1022this.selected = this.selectList.getFocus()[0];10231024// Shift selection up if we land on a disabled option1025if (this.options[this.selected].isDisabled && this.selected > 0) {1026this.selected--;1027this.selectList.setFocus([this.selected]);1028}1029this.selectList.reveal(this.selected);1030this.select(this.selected);1031}, 1);1032}10331034private onHome(e: StandardKeyboardEvent): void {1035dom.EventHelper.stop(e);10361037if (this.options.length < 2) {1038return;1039}1040this.selected = 0;1041if (this.options[this.selected].isDisabled && this.selected > 1) {1042this.selected++;1043}1044this.selectList.setFocus([this.selected]);1045this.selectList.reveal(this.selected);1046this.select(this.selected);1047}10481049private onEnd(e: StandardKeyboardEvent): void {1050dom.EventHelper.stop(e);10511052if (this.options.length < 2) {1053return;1054}1055this.selected = this.options.length - 1;1056if (this.options[this.selected].isDisabled && this.selected > 1) {1057this.selected--;1058}1059this.selectList.setFocus([this.selected]);1060this.selectList.reveal(this.selected);1061this.select(this.selected);1062}10631064// Mimic option first character navigation of native select1065private onCharacter(e: StandardKeyboardEvent): void {1066const ch = KeyCodeUtils.toString(e.keyCode);1067let optionIndex = -1;10681069for (let i = 0; i < this.options.length - 1; i++) {1070optionIndex = (i + this.selected + 1) % this.options.length;1071if (this.options[optionIndex].text.charAt(0).toUpperCase() === ch && !this.options[optionIndex].isDisabled) {1072this.select(optionIndex);1073this.selectList.setFocus([optionIndex]);1074this.selectList.reveal(this.selectList.getFocus()[0]);1075dom.EventHelper.stop(e);1076break;1077}1078}1079}10801081public override dispose(): void {1082this.hideSelectDropDown(false);1083super.dispose();1084}1085}108610871088