Path: blob/main/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts
5244 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, DisposableStore, 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 { IRenderedMarkdown, 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 readonly _selectionDetailsDisposables = this._register(new DisposableStore());106private _skipLayout: boolean = false;107private _cachedMaxDetailsHeight?: number;108private _hover?: IManagedHover;109110private _sticky: boolean = false; // for dev purposes only111112constructor(options: ISelectOptionItem[], selected: number, contextViewProvider: IContextViewProvider, styles: ISelectBoxStyles, selectBoxOptions?: ISelectBoxOptions) {113114super();115this._isVisible = false;116this.styles = styles;117118this.selectBoxOptions = selectBoxOptions || Object.create(null);119120if (typeof this.selectBoxOptions.minBottomMargin !== 'number') {121this.selectBoxOptions.minBottomMargin = SelectBoxList.DEFAULT_DROPDOWN_MINIMUM_BOTTOM_MARGIN;122} else if (this.selectBoxOptions.minBottomMargin < 0) {123this.selectBoxOptions.minBottomMargin = 0;124}125126this.selectElement = document.createElement('select');127this.selectElement.className = 'monaco-select-box';128129if (typeof this.selectBoxOptions.ariaLabel === 'string') {130this.selectElement.setAttribute('aria-label', this.selectBoxOptions.ariaLabel);131}132133if (typeof this.selectBoxOptions.ariaDescription === 'string') {134this.selectElement.setAttribute('aria-description', this.selectBoxOptions.ariaDescription);135}136137this._onDidSelect = new Emitter<ISelectData>();138this._register(this._onDidSelect);139140this.registerListeners();141this.constructSelectDropDown(contextViewProvider);142143this.selected = selected || 0;144145if (options) {146this.setOptions(options, selected);147}148149this.initStyleSheet();150151}152153private setTitle(title: string): void {154if (!this._hover && title) {155this._hover = this._register(getBaseLayerHoverDelegate().setupManagedHover(getDefaultHoverDelegate('mouse'), this.selectElement, title));156} else if (this._hover) {157this._hover.update(title);158}159}160161// IDelegate - List renderer162163getHeight(): number {164return 22;165}166167getTemplateId(): string {168return SELECT_OPTION_ENTRY_TEMPLATE_ID;169}170171private constructSelectDropDown(contextViewProvider: IContextViewProvider) {172173// SetUp ContextView container to hold select Dropdown174this.contextViewProvider = contextViewProvider;175this.selectDropDownContainer = dom.$('.monaco-select-box-dropdown-container');176177// Setup container for select option details178this.selectionDetailsPane = dom.append(this.selectDropDownContainer, $('.select-box-details-pane'));179180// Create span flex box item/div we can measure and control181const widthControlOuterDiv = dom.append(this.selectDropDownContainer, $('.select-box-dropdown-container-width-control'));182const widthControlInnerDiv = dom.append(widthControlOuterDiv, $('.width-control-div'));183this.widthControlElement = document.createElement('span');184this.widthControlElement.className = 'option-text-width-control';185dom.append(widthControlInnerDiv, this.widthControlElement);186187// Always default to below position188this._dropDownPosition = AnchorPosition.BELOW;189190// Inline stylesheet for themes191this.styleElement = domStylesheetsJs.createStyleSheet(this.selectDropDownContainer);192193// Prevent dragging of dropdown #114329194this.selectDropDownContainer.setAttribute('draggable', 'true');195this._register(dom.addDisposableListener(this.selectDropDownContainer, dom.EventType.DRAG_START, (e) => {196dom.EventHelper.stop(e, true);197}));198}199200private registerListeners() {201202// Parent native select keyboard listeners203204this._register(dom.addStandardDisposableListener(this.selectElement, 'change', (e) => {205this.selected = e.target.selectedIndex;206this._onDidSelect.fire({207index: e.target.selectedIndex,208selected: e.target.value209});210if (!!this.options[this.selected] && !!this.options[this.selected].text) {211this.setTitle(this.options[this.selected].text);212}213}));214215// Have to implement both keyboard and mouse controllers to handle disabled options216// Intercept mouse events to override normal select actions on parents217218this._register(dom.addDisposableListener(this.selectElement, dom.EventType.CLICK, (e) => {219dom.EventHelper.stop(e);220221if (this._isVisible) {222this.hideSelectDropDown(true);223} else {224this.showSelectDropDown();225}226}));227228this._register(dom.addDisposableListener(this.selectElement, dom.EventType.MOUSE_DOWN, (e) => {229dom.EventHelper.stop(e);230}));231232// Intercept touch events233// The following implementation is slightly different from the mouse event handlers above.234// Use the following helper variable, otherwise the list flickers.235let listIsVisibleOnTouchStart: boolean;236this._register(dom.addDisposableListener(this.selectElement, 'touchstart', (e) => {237listIsVisibleOnTouchStart = this._isVisible;238}));239this._register(dom.addDisposableListener(this.selectElement, 'touchend', (e) => {240dom.EventHelper.stop(e);241242if (listIsVisibleOnTouchStart) {243this.hideSelectDropDown(true);244} else {245this.showSelectDropDown();246}247}));248249// Intercept keyboard handling250251this._register(dom.addDisposableListener(this.selectElement, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {252const event = new StandardKeyboardEvent(e);253let showDropDown = false;254255// Create and drop down select list on keyboard select256if (isMacintosh) {257if (event.keyCode === KeyCode.DownArrow || event.keyCode === KeyCode.UpArrow || event.keyCode === KeyCode.Space || event.keyCode === KeyCode.Enter) {258showDropDown = true;259}260} else {261if (event.keyCode === KeyCode.DownArrow && event.altKey || event.keyCode === KeyCode.UpArrow && event.altKey || event.keyCode === KeyCode.Space || event.keyCode === KeyCode.Enter) {262showDropDown = true;263}264}265266if (showDropDown) {267this.showSelectDropDown();268dom.EventHelper.stop(e, true);269}270}));271}272273public get onDidSelect(): Event<ISelectData> {274return this._onDidSelect.event;275}276277public setOptions(options: ISelectOptionItem[], selected?: number): void {278if (!arrays.equals(this.options, options)) {279this.options = options;280this.selectElement.options.length = 0;281this._hasDetails = false;282this._cachedMaxDetailsHeight = undefined;283284this.options.forEach((option, index) => {285this.selectElement.add(this.createOption(option.text, index, option.isDisabled));286if (typeof option.description === 'string') {287this._hasDetails = true;288}289});290}291292if (selected !== undefined) {293this.select(selected);294// Set current = selected since this is not necessarily a user exit295this._currentSelection = this.selected;296}297}298299public setEnabled(enable: boolean): void {300this.selectElement.disabled = !enable;301}302303private setOptionsList() {304305// Mirror options in drop-down306// Populate select list for non-native select mode307this.selectList?.splice(0, this.selectList.length, this.options);308}309310public select(index: number): void {311312if (index >= 0 && index < this.options.length) {313this.selected = index;314} else if (index > this.options.length - 1) {315// Adjust index to end of list316// This could make client out of sync with the select317this.select(this.options.length - 1);318} else if (this.selected < 0) {319this.selected = 0;320}321322this.selectElement.selectedIndex = this.selected;323if (!!this.options[this.selected] && !!this.options[this.selected].text) {324this.setTitle(this.options[this.selected].text);325}326}327328public setAriaLabel(label: string): void {329this.selectBoxOptions.ariaLabel = label;330this.selectElement.setAttribute('aria-label', this.selectBoxOptions.ariaLabel);331}332333public focus(): void {334if (this.selectElement) {335this.selectElement.tabIndex = 0;336this.selectElement.focus();337}338}339340public blur(): void {341if (this.selectElement) {342this.selectElement.tabIndex = -1;343this.selectElement.blur();344}345}346347public setFocusable(focusable: boolean): void {348this.selectElement.tabIndex = focusable ? 0 : -1;349}350351public render(container: HTMLElement): void {352this.container = container;353container.classList.add('select-container');354container.appendChild(this.selectElement);355this.styleSelectElement();356}357358private initStyleSheet(): void {359360const content: string[] = [];361362// Style non-native select mode363364if (this.styles.listFocusBackground) {365content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.focused { background-color: ${this.styles.listFocusBackground} !important; }`);366}367368if (this.styles.listFocusForeground) {369content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.focused { color: ${this.styles.listFocusForeground} !important; }`);370}371372if (this.styles.decoratorRightForeground) {373content.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}; }`);374}375376if (this.styles.selectBackground && this.styles.selectBorder && this.styles.selectBorder !== this.styles.selectBackground) {377content.push(`.monaco-select-box-dropdown-container { border: 1px solid ${this.styles.selectBorder} } `);378content.push(`.monaco-select-box-dropdown-container > .select-box-details-pane.border-top { border-top: 1px solid ${this.styles.selectBorder} } `);379content.push(`.monaco-select-box-dropdown-container > .select-box-details-pane.border-bottom { border-bottom: 1px solid ${this.styles.selectBorder} } `);380381}382else if (this.styles.selectListBorder) {383content.push(`.monaco-select-box-dropdown-container > .select-box-details-pane.border-top { border-top: 1px solid ${this.styles.selectListBorder} } `);384content.push(`.monaco-select-box-dropdown-container > .select-box-details-pane.border-bottom { border-bottom: 1px solid ${this.styles.selectListBorder} } `);385}386387// Hover foreground - ignore for disabled options388if (this.styles.listHoverForeground) {389content.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; }`);390}391392// Hover background - ignore for disabled options393if (this.styles.listHoverBackground) {394content.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; }`);395}396397// Match quick input outline styles - ignore for disabled options398if (this.styles.listFocusOutline) {399content.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; }`);400}401402if (this.styles.listHoverOutline) {403content.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; }`);404}405406// Clear list styles on focus and on hover for disabled options407content.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; }`);408content.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; }`);409410this.styleElement.textContent = content.join('\n');411}412413private styleSelectElement(): void {414const background = this.styles.selectBackground ?? '';415const foreground = this.styles.selectForeground ?? '';416const border = this.styles.selectBorder ?? '';417418this.selectElement.style.backgroundColor = background;419this.selectElement.style.color = foreground;420this.selectElement.style.borderColor = border;421}422423private styleList() {424const background = this.styles.selectBackground ?? '';425426const listBackground = cssJs.asCssValueWithDefault(this.styles.selectListBackground, background);427this.selectDropDownListContainer.style.backgroundColor = listBackground;428this.selectionDetailsPane.style.backgroundColor = listBackground;429const optionsBorder = this.styles.focusBorder ?? '';430this.selectDropDownContainer.style.outlineColor = optionsBorder;431this.selectDropDownContainer.style.outlineOffset = '-1px';432433this.selectList.style(this.styles);434}435436private createOption(value: string, index: number, disabled?: boolean): HTMLOptionElement {437const option = document.createElement('option');438option.value = value;439option.text = value;440option.disabled = !!disabled;441442return option;443}444445// ContextView dropdown methods446447private showSelectDropDown() {448this.selectionDetailsPane.textContent = '';449450if (!this.contextViewProvider || this._isVisible) {451return;452}453454// Lazily create and populate list only at open, moved from constructor455this.createSelectList(this.selectDropDownContainer);456this.setOptionsList();457458// This allows us to flip the position based on measurement459// Set drop-down position above/below from required height and margins460// If pre-layout cannot fit at least one option do not show drop-down461462this.contextViewProvider.showContextView({463getAnchor: () => this.selectElement,464render: (container: HTMLElement) => this.renderSelectDropDown(container, true),465layout: () => {466this.layoutSelectDropDown();467},468onHide: () => {469this.selectDropDownContainer.classList.remove('visible');470},471anchorPosition: this._dropDownPosition472}, this.selectBoxOptions.optionsAsChildren ? this.container : undefined);473474// Hide so we can relay out475this._isVisible = true;476this.hideSelectDropDown(false);477478this.contextViewProvider.showContextView({479getAnchor: () => this.selectElement,480render: (container: HTMLElement) => this.renderSelectDropDown(container),481layout: () => this.layoutSelectDropDown(),482onHide: () => {483this.selectDropDownContainer.classList.remove('visible');484},485anchorPosition: this._dropDownPosition486}, this.selectBoxOptions.optionsAsChildren ? this.container : undefined);487488// Track initial selection the case user escape, blur489this._currentSelection = this.selected;490this._isVisible = true;491this.selectElement.setAttribute('aria-expanded', 'true');492}493494private hideSelectDropDown(focusSelect: boolean) {495if (!this.contextViewProvider || !this._isVisible) {496return;497}498499this._isVisible = false;500this.selectElement.setAttribute('aria-expanded', 'false');501502if (focusSelect) {503this.selectElement.focus();504}505506this.contextViewProvider.hideContextView();507}508509private renderSelectDropDown(container: HTMLElement, preLayoutPosition?: boolean): IDisposable {510container.appendChild(this.selectDropDownContainer);511512// Pre-Layout allows us to change position513this.layoutSelectDropDown(preLayoutPosition);514515return {516dispose: () => {517// contextView will dispose itself if moving from one View to another518this.selectDropDownContainer.remove(); // remove to take out the CSS rules we add519}520};521}522523// Iterate over detailed descriptions, find max height524private measureMaxDetailsHeight(): number {525let maxDetailsPaneHeight = 0;526this.options.forEach((_option, index) => {527this.updateDetail(index);528529if (this.selectionDetailsPane.offsetHeight > maxDetailsPaneHeight) {530maxDetailsPaneHeight = this.selectionDetailsPane.offsetHeight;531}532});533534return maxDetailsPaneHeight;535}536537private layoutSelectDropDown(preLayoutPosition?: boolean): boolean {538539// Avoid recursion from layout called in onListFocus540if (this._skipLayout) {541return false;542}543544// Layout ContextView drop down select list and container545// Have to manage our vertical overflow, sizing, position below or above546// Position has to be determined and set prior to contextView instantiation547548if (this.selectList) {549550// Make visible to enable measurements551this.selectDropDownContainer.classList.add('visible');552553const window = dom.getWindow(this.selectElement);554const selectPosition = dom.getDomNodePagePosition(this.selectElement);555const maxSelectDropDownHeightBelow = (window.innerHeight - selectPosition.top - selectPosition.height - (this.selectBoxOptions.minBottomMargin || 0));556const maxSelectDropDownHeightAbove = (selectPosition.top - SelectBoxList.DEFAULT_DROPDOWN_MINIMUM_TOP_MARGIN);557558// Determine optimal width - min(longest option), opt(parent select, excluding margins), max(ContextView controlled)559const selectWidth = this.selectElement.offsetWidth;560const selectMinWidth = this.setWidthControlElement(this.widthControlElement);561const selectOptimalWidth = `${Math.max(selectMinWidth, Math.round(selectWidth))}px`;562563this.selectDropDownContainer.style.width = selectOptimalWidth;564565// Get initial list height and determine space above and below566this.selectList.getHTMLElement().style.height = '';567this.selectList.layout();568let listHeight = this.selectList.contentHeight;569570if (this._hasDetails && this._cachedMaxDetailsHeight === undefined) {571this._cachedMaxDetailsHeight = this.measureMaxDetailsHeight();572}573const maxDetailsPaneHeight = this._hasDetails ? this._cachedMaxDetailsHeight! : 0;574575const minRequiredDropDownHeight = listHeight + maxDetailsPaneHeight;576const maxVisibleOptionsBelow = ((Math.floor((maxSelectDropDownHeightBelow - maxDetailsPaneHeight) / this.getHeight())));577const maxVisibleOptionsAbove = ((Math.floor((maxSelectDropDownHeightAbove - maxDetailsPaneHeight) / this.getHeight())));578579// If we are only doing pre-layout check/adjust position only580// Calculate vertical space available, flip up if insufficient581// Use reflected padding on parent select, ContextView style582// properties not available before DOM attachment583584if (preLayoutPosition) {585586// Check if select moved out of viewport , do not open587// If at least one option cannot be shown, don't open the drop-down or hide/remove if open588589if ((selectPosition.top + selectPosition.height) > (window.innerHeight - 22)590|| selectPosition.top < SelectBoxList.DEFAULT_DROPDOWN_MINIMUM_TOP_MARGIN591|| ((maxVisibleOptionsBelow < 1) && (maxVisibleOptionsAbove < 1))) {592// Indicate we cannot open593return false;594}595596// Determine if we have to flip up597// Always show complete list items - never more than Max available vertical height598if (maxVisibleOptionsBelow < SelectBoxList.DEFAULT_MINIMUM_VISIBLE_OPTIONS599&& maxVisibleOptionsAbove > maxVisibleOptionsBelow600&& this.options.length > maxVisibleOptionsBelow601) {602this._dropDownPosition = AnchorPosition.ABOVE;603this.selectDropDownListContainer.remove();604this.selectionDetailsPane.remove();605this.selectDropDownContainer.appendChild(this.selectionDetailsPane);606this.selectDropDownContainer.appendChild(this.selectDropDownListContainer);607608this.selectionDetailsPane.classList.remove('border-top');609this.selectionDetailsPane.classList.add('border-bottom');610611} else {612this._dropDownPosition = AnchorPosition.BELOW;613this.selectDropDownListContainer.remove();614this.selectionDetailsPane.remove();615this.selectDropDownContainer.appendChild(this.selectDropDownListContainer);616this.selectDropDownContainer.appendChild(this.selectionDetailsPane);617618this.selectionDetailsPane.classList.remove('border-bottom');619this.selectionDetailsPane.classList.add('border-top');620}621// Do full layout on showSelectDropDown only622return true;623}624625// Check if select out of viewport or cutting into status bar626if ((selectPosition.top + selectPosition.height) > (window.innerHeight - 22)627|| selectPosition.top < SelectBoxList.DEFAULT_DROPDOWN_MINIMUM_TOP_MARGIN628|| (this._dropDownPosition === AnchorPosition.BELOW && maxVisibleOptionsBelow < 1)629|| (this._dropDownPosition === AnchorPosition.ABOVE && maxVisibleOptionsAbove < 1)) {630// Cannot properly layout, close and hide631this.hideSelectDropDown(true);632return false;633}634635// SetUp list dimensions and layout - account for container padding636// Use position to check above or below available space637if (this._dropDownPosition === AnchorPosition.BELOW) {638if (this._isVisible && maxVisibleOptionsBelow + maxVisibleOptionsAbove < 1) {639// If drop-down is visible, must be doing a DOM re-layout, hide since we don't fit640// Hide drop-down, hide contextview, focus on parent select641this.hideSelectDropDown(true);642return false;643}644645// Adjust list height to max from select bottom to margin (default/minBottomMargin)646if (minRequiredDropDownHeight > maxSelectDropDownHeightBelow) {647listHeight = (maxVisibleOptionsBelow * this.getHeight());648}649} else {650if (minRequiredDropDownHeight > maxSelectDropDownHeightAbove) {651listHeight = (maxVisibleOptionsAbove * this.getHeight());652}653}654655// Set adjusted list height and relayout656this.selectList.layout(listHeight);657this.selectList.domFocus();658659// Finally set focus on selected item660if (this.selectList.length > 0) {661this.selectList.setFocus([this.selected || 0]);662this.selectList.reveal(this.selectList.getFocus()[0] || 0);663}664665if (this._hasDetails) {666// Leave the selectDropDownContainer to size itself according to children (list + details) - #57447667this.selectList.getHTMLElement().style.height = `${listHeight}px`;668this.selectDropDownContainer.style.height = '';669} else {670this.selectDropDownContainer.style.height = `${listHeight}px`;671}672673this.updateDetail(this.selected);674675this.selectDropDownContainer.style.width = selectOptimalWidth;676this.selectDropDownListContainer.setAttribute('tabindex', '0');677678return true;679} else {680return false;681}682}683684private setWidthControlElement(container: HTMLElement): number {685let elementWidth = 0;686687if (container) {688let longest = 0;689let longestLength = 0;690691this.options.forEach((option, index) => {692const detailLength = !!option.detail ? option.detail.length : 0;693const rightDecoratorLength = !!option.decoratorRight ? option.decoratorRight.length : 0;694695const len = option.text.length + detailLength + rightDecoratorLength;696if (len > longestLength) {697longest = index;698longestLength = len;699}700});701702703container.textContent = this.options[longest].text + (!!this.options[longest].decoratorRight ? `${this.options[longest].decoratorRight} ` : '');704elementWidth = dom.getTotalWidth(container);705}706707return elementWidth;708}709710private createSelectList(parent: HTMLElement): void {711712// If we have already constructive list on open, skip713if (this.selectList) {714return;715}716717// SetUp container for list718this.selectDropDownListContainer = dom.append(parent, $('.select-box-dropdown-list-container'));719720this.listRenderer = new SelectListRenderer();721722this.selectList = this._register(new List('SelectBoxCustom', this.selectDropDownListContainer, this, [this.listRenderer], {723useShadows: false,724verticalScrollMode: ScrollbarVisibility.Visible,725keyboardSupport: false,726mouseSupport: false,727accessibilityProvider: {728getAriaLabel: element => {729let label = element.text;730if (element.detail) {731label += `. ${element.detail}`;732}733734if (element.decoratorRight) {735label += `. ${element.decoratorRight}`;736}737738if (element.description) {739label += `. ${element.description}`;740}741742return label;743},744getWidgetAriaLabel: () => localize({ key: 'selectBox', comment: ['Behave like native select dropdown element.'] }, "Select Box"),745getRole: () => isMacintosh ? '' : 'option',746getWidgetRole: () => 'listbox'747}748}));749if (this.selectBoxOptions.ariaLabel) {750this.selectList.ariaLabel = this.selectBoxOptions.ariaLabel;751}752753// SetUp list keyboard controller - control navigation, disabled items, focus754const onKeyDown = this._register(new DomEmitter(this.selectDropDownListContainer, 'keydown'));755const onSelectDropDownKeyDown = Event.chain(onKeyDown.event, $ =>756$.filter(() => this.selectList.length > 0)757.map(e => new StandardKeyboardEvent(e))758);759760this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.Enter))(this.onEnter, this));761this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.Tab))(this.onEnter, this)); // Tab should behave the same as enter, #79339762this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.Escape))(this.onEscape, this));763this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.UpArrow))(this.onUpArrow, this));764this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.DownArrow))(this.onDownArrow, this));765this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.PageDown))(this.onPageDown, this));766this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.PageUp))(this.onPageUp, this));767this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.Home))(this.onHome, this));768this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.End))(this.onEnd, this));769this._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));770771// SetUp list mouse controller - control navigation, disabled items, focus772this._register(dom.addDisposableListener(this.selectList.getHTMLElement(), dom.EventType.POINTER_UP, e => this.onPointerUp(e)));773774this._register(this.selectList.onMouseOver(e => typeof e.index !== 'undefined' && this.selectList.setFocus([e.index])));775this._register(this.selectList.onDidChangeFocus(e => this.onListFocus(e)));776777this._register(dom.addDisposableListener(this.selectDropDownContainer, dom.EventType.FOCUS_OUT, e => {778if (!this._isVisible || dom.isAncestor(e.relatedTarget as HTMLElement, this.selectDropDownContainer)) {779return;780}781this.onListBlur();782}));783784this.selectList.getHTMLElement().setAttribute('aria-label', this.selectBoxOptions.ariaLabel || '');785this.selectList.getHTMLElement().setAttribute('aria-expanded', 'true');786787this.styleList();788}789790// List methods791792// List mouse controller - active exit, select option, fire onDidSelect if change, return focus to parent select793// Also takes in touchend events794private onPointerUp(e: PointerEvent): void {795796if (!this.selectList.length) {797return;798}799800dom.EventHelper.stop(e);801802const target = <Element>e.target;803if (!target) {804return;805}806807// Check our mouse event is on an option (not scrollbar)808if (target.classList.contains('slider')) {809return;810}811812const listRowElement = target.closest('.monaco-list-row');813814if (!listRowElement) {815return;816}817const index = Number(listRowElement.getAttribute('data-index'));818const disabled = listRowElement.classList.contains('option-disabled');819820// Ignore mouse selection of disabled options821if (index >= 0 && index < this.options.length && !disabled) {822this.selected = index;823this.select(this.selected);824825this.selectList.setFocus([this.selected]);826this.selectList.reveal(this.selectList.getFocus()[0]);827828// Only fire if selection change829if (this.selected !== this._currentSelection) {830// Set current = selected831this._currentSelection = this.selected;832833this._onDidSelect.fire({834index: this.selectElement.selectedIndex,835selected: this.options[this.selected].text836837});838if (!!this.options[this.selected] && !!this.options[this.selected].text) {839this.setTitle(this.options[this.selected].text);840}841}842843this.hideSelectDropDown(true);844}845}846847// List Exit - passive - implicit no selection change, hide drop-down848private onListBlur(): void {849if (this._sticky) { return; }850if (this.selected !== this._currentSelection) {851// Reset selected to current if no change852this.select(this._currentSelection);853}854855this.hideSelectDropDown(false);856}857858859private renderDescriptionMarkdown(text: string, actionHandler?: MarkdownActionHandler): IRenderedMarkdown {860const cleanRenderedMarkdown = (element: Node) => {861for (let i = 0; i < element.childNodes.length; i++) {862const child = <Element>element.childNodes.item(i);863864const tagName = child.tagName && child.tagName.toLowerCase();865if (tagName === 'img') {866child.remove();867} else {868cleanRenderedMarkdown(child);869}870}871};872873const rendered = renderMarkdown({ value: text, supportThemeIcons: true }, { actionHandler });874875rendered.element.classList.add('select-box-description-markdown');876cleanRenderedMarkdown(rendered.element);877878return rendered;879}880881// List Focus Change - passive - update details pane with newly focused element's data882private onListFocus(e: IListEvent<ISelectOptionItem>) {883// Skip during initial layout884if (!this._isVisible || !this._hasDetails) {885return;886}887888this.updateDetail(e.indexes[0]);889}890891private updateDetail(selectedIndex: number): void {892// Reset893this._selectionDetailsDisposables.clear();894this.selectionDetailsPane.textContent = '';895896const option = this.options[selectedIndex];897const description = option?.description ?? '';898const descriptionIsMarkdown = option?.descriptionIsMarkdown ?? false;899900if (description) {901if (descriptionIsMarkdown) {902const actionHandler = option.descriptionMarkdownActionHandler;903const result = this._selectionDetailsDisposables.add(this.renderDescriptionMarkdown(description, actionHandler));904this.selectionDetailsPane.appendChild(result.element);905} else {906this.selectionDetailsPane.textContent = description;907}908this.selectionDetailsPane.style.display = 'block';909} else {910this.selectionDetailsPane.style.display = 'none';911}912913// Avoid recursion914this._skipLayout = true;915this.contextViewProvider.layout();916this._skipLayout = false;917}918919// List keyboard controller920921// List exit - active - hide ContextView dropdown, reset selection, return focus to parent select922private onEscape(e: StandardKeyboardEvent): void {923dom.EventHelper.stop(e);924925// Reset selection to value when opened926this.select(this._currentSelection);927this.hideSelectDropDown(true);928}929930// List exit - active - hide ContextView dropdown, return focus to parent select, fire onDidSelect if change931private onEnter(e: StandardKeyboardEvent): void {932dom.EventHelper.stop(e);933934// Only fire if selection change935if (this.selected !== this._currentSelection) {936this._currentSelection = this.selected;937this._onDidSelect.fire({938index: this.selectElement.selectedIndex,939selected: this.options[this.selected].text940});941if (!!this.options[this.selected] && !!this.options[this.selected].text) {942this.setTitle(this.options[this.selected].text);943}944}945946this.hideSelectDropDown(true);947}948949// List navigation - have to handle a disabled option (jump over)950private onDownArrow(e: StandardKeyboardEvent): void {951if (this.selected < this.options.length - 1) {952dom.EventHelper.stop(e, true);953954// Skip disabled options955const nextOptionDisabled = this.options[this.selected + 1].isDisabled;956957if (nextOptionDisabled && this.options.length > this.selected + 2) {958this.selected += 2;959} else if (nextOptionDisabled) {960return;961} else {962this.selected++;963}964965// Set focus/selection - only fire event when closing drop-down or on blur966this.select(this.selected);967this.selectList.setFocus([this.selected]);968this.selectList.reveal(this.selectList.getFocus()[0]);969}970}971972private onUpArrow(e: StandardKeyboardEvent): void {973if (this.selected > 0) {974dom.EventHelper.stop(e, true);975// Skip disabled options976const previousOptionDisabled = this.options[this.selected - 1].isDisabled;977if (previousOptionDisabled && this.selected > 1) {978this.selected -= 2;979} else {980this.selected--;981}982// Set focus/selection - only fire event when closing drop-down or on blur983this.select(this.selected);984this.selectList.setFocus([this.selected]);985this.selectList.reveal(this.selectList.getFocus()[0]);986}987}988989private onPageUp(e: StandardKeyboardEvent): void {990dom.EventHelper.stop(e);991992this.selectList.focusPreviousPage();993994// Allow scrolling to settle995setTimeout(() => {996this.selected = this.selectList.getFocus()[0];997998// Shift selection down if we land on a disabled option999if (this.options[this.selected].isDisabled && this.selected < this.options.length - 1) {1000this.selected++;1001this.selectList.setFocus([this.selected]);1002}1003this.selectList.reveal(this.selected);1004this.select(this.selected);1005}, 1);1006}10071008private onPageDown(e: StandardKeyboardEvent): void {1009dom.EventHelper.stop(e);10101011this.selectList.focusNextPage();10121013// Allow scrolling to settle1014setTimeout(() => {1015this.selected = this.selectList.getFocus()[0];10161017// Shift selection up if we land on a disabled option1018if (this.options[this.selected].isDisabled && this.selected > 0) {1019this.selected--;1020this.selectList.setFocus([this.selected]);1021}1022this.selectList.reveal(this.selected);1023this.select(this.selected);1024}, 1);1025}10261027private onHome(e: StandardKeyboardEvent): void {1028dom.EventHelper.stop(e);10291030if (this.options.length < 2) {1031return;1032}1033this.selected = 0;1034if (this.options[this.selected].isDisabled && this.selected > 1) {1035this.selected++;1036}1037this.selectList.setFocus([this.selected]);1038this.selectList.reveal(this.selected);1039this.select(this.selected);1040}10411042private onEnd(e: StandardKeyboardEvent): void {1043dom.EventHelper.stop(e);10441045if (this.options.length < 2) {1046return;1047}1048this.selected = this.options.length - 1;1049if (this.options[this.selected].isDisabled && this.selected > 1) {1050this.selected--;1051}1052this.selectList.setFocus([this.selected]);1053this.selectList.reveal(this.selected);1054this.select(this.selected);1055}10561057// Mimic option first character navigation of native select1058private onCharacter(e: StandardKeyboardEvent): void {1059const ch = KeyCodeUtils.toString(e.keyCode);1060let optionIndex = -1;10611062for (let i = 0; i < this.options.length - 1; i++) {1063optionIndex = (i + this.selected + 1) % this.options.length;1064if (this.options[optionIndex].text.charAt(0).toUpperCase() === ch && !this.options[optionIndex].isDisabled) {1065this.select(optionIndex);1066this.selectList.setFocus([optionIndex]);1067this.selectList.reveal(this.selectList.getFocus()[0]);1068dom.EventHelper.stop(e);1069break;1070}1071}1072}10731074public override dispose(): void {1075this.hideSelectDropDown(false);1076super.dispose();1077}1078}107910801081