Path: blob/main/src/vs/base/browser/ui/inputbox/inputBox.ts
5222 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 * as dom from '../../dom.js';6import * as cssJs from '../../cssValue.js';7import { DomEmitter } from '../../event.js';8import { renderFormattedText, renderText } from '../../formattedTextRenderer.js';9import { IHistoryNavigationWidget } from '../../history.js';10import { ActionBar, IActionViewItemProvider } from '../actionbar/actionbar.js';11import * as aria from '../aria/aria.js';12import { AnchorAlignment, IContextViewProvider } from '../contextview/contextview.js';13import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js';14import { ScrollableElement } from '../scrollbar/scrollableElement.js';15import { Widget } from '../widget.js';16import { IAction } from '../../../common/actions.js';17import { Emitter, Event } from '../../../common/event.js';18import { HistoryNavigator, IHistory } from '../../../common/history.js';19import { equals } from '../../../common/objects.js';20import { ScrollbarVisibility } from '../../../common/scrollable.js';21import './inputBox.css';22import * as nls from '../../../../nls.js';23import { MutableDisposable, type IDisposable } from '../../../common/lifecycle.js';242526const $ = dom.$;2728export interface IInputOptions {29readonly placeholder?: string;30readonly showPlaceholderOnFocus?: boolean;31readonly tooltip?: string;32readonly ariaLabel?: string;33readonly type?: string;34readonly validationOptions?: IInputValidationOptions;35readonly flexibleHeight?: boolean;36readonly flexibleWidth?: boolean;37readonly flexibleMaxHeight?: number;38readonly actions?: ReadonlyArray<IAction>;39readonly actionViewItemProvider?: IActionViewItemProvider;40readonly inputBoxStyles: IInputBoxStyles;41readonly history?: IHistory<string>;42readonly hideHoverOnValueChange?: boolean;43}4445export interface IInputBoxStyles {46readonly inputBackground: string | undefined;47readonly inputForeground: string | undefined;48readonly inputBorder: string | undefined;49readonly inputValidationInfoBorder: string | undefined;50readonly inputValidationInfoBackground: string | undefined;51readonly inputValidationInfoForeground: string | undefined;52readonly inputValidationWarningBorder: string | undefined;53readonly inputValidationWarningBackground: string | undefined;54readonly inputValidationWarningForeground: string | undefined;55readonly inputValidationErrorBorder: string | undefined;56readonly inputValidationErrorBackground: string | undefined;57readonly inputValidationErrorForeground: string | undefined;58}5960export interface IInputValidator {61(value: string): IMessage | null;62}6364export interface IMessage {65readonly content?: string;66readonly formatContent?: boolean; // defaults to false67readonly type?: MessageType;68}6970export interface IInputValidationOptions {71validation?: IInputValidator;72}7374export const enum MessageType {75INFO = 1,76WARNING = 2,77ERROR = 378}7980export interface IRange {81start: number;82end: number;83}8485export const unthemedInboxStyles: IInputBoxStyles = {86inputBackground: '#3C3C3C',87inputForeground: '#CCCCCC',88inputValidationInfoBorder: '#55AAFF',89inputValidationInfoBackground: '#063B49',90inputValidationWarningBorder: '#B89500',91inputValidationWarningBackground: '#352A05',92inputValidationErrorBorder: '#BE1100',93inputValidationErrorBackground: '#5A1D1D',94inputBorder: undefined,95inputValidationErrorForeground: undefined,96inputValidationInfoForeground: undefined,97inputValidationWarningForeground: undefined98};99100export class InputBox extends Widget {101private contextViewProvider?: IContextViewProvider;102element: HTMLElement;103protected input: HTMLInputElement;104private actionbar?: ActionBar;105private readonly options: IInputOptions;106private message: IMessage | null;107protected placeholder: string;108private tooltip: string;109private ariaLabel: string;110private validation?: IInputValidator;111private state: 'idle' | 'open' | 'closed' = 'idle';112113private mirror: HTMLElement | undefined;114private cachedHeight: number | undefined;115private cachedContentHeight: number | undefined;116private maxHeight: number = Number.POSITIVE_INFINITY;117private scrollableElement: ScrollableElement | undefined;118private readonly hover: MutableDisposable<IDisposable> = this._register(new MutableDisposable());119120private _onDidChange = this._register(new Emitter<string>());121public get onDidChange(): Event<string> { return this._onDidChange.event; }122123private _onDidHeightChange = this._register(new Emitter<number>());124public get onDidHeightChange(): Event<number> { return this._onDidHeightChange.event; }125126constructor(container: HTMLElement, contextViewProvider: IContextViewProvider | undefined, options: IInputOptions) {127super();128129this.contextViewProvider = contextViewProvider;130this.options = options;131132this.message = null;133this.placeholder = this.options.placeholder || '';134this.tooltip = this.options.tooltip ?? (this.placeholder || '');135this.ariaLabel = this.options.ariaLabel || '';136137if (this.options.validationOptions) {138this.validation = this.options.validationOptions.validation;139}140141this.element = dom.append(container, $('.monaco-inputbox.idle'));142143const tagName = this.options.flexibleHeight ? 'textarea' : 'input';144145const wrapper = dom.append(this.element, $('.ibwrapper'));146this.input = dom.append(wrapper, $(tagName + '.input.empty'));147this.input.setAttribute('autocorrect', 'off');148this.input.setAttribute('autocapitalize', 'off');149this.input.setAttribute('spellcheck', 'false');150151this.onfocus(this.input, () => this.element.classList.add('synthetic-focus'));152this.onblur(this.input, () => this.element.classList.remove('synthetic-focus'));153154if (this.options.flexibleHeight) {155this.maxHeight = typeof this.options.flexibleMaxHeight === 'number' ? this.options.flexibleMaxHeight : Number.POSITIVE_INFINITY;156157this.mirror = dom.append(wrapper, $('div.mirror'));158this.mirror.innerText = '\u00a0';159160this.scrollableElement = new ScrollableElement(this.element, { vertical: ScrollbarVisibility.Auto });161162if (this.options.flexibleWidth) {163this.input.setAttribute('wrap', 'off');164this.mirror.style.whiteSpace = 'pre';165this.mirror.style.wordWrap = 'initial';166}167168dom.append(container, this.scrollableElement.getDomNode());169this._register(this.scrollableElement);170171// from ScrollableElement to DOM172this._register(this.scrollableElement.onScroll(e => this.input.scrollTop = e.scrollTop));173174const onSelectionChange = this._register(new DomEmitter(container.ownerDocument, 'selectionchange'));175const onAnchoredSelectionChange = Event.filter(onSelectionChange.event, () => {176const selection = container.ownerDocument.getSelection();177return selection?.anchorNode === wrapper;178});179180// from DOM to ScrollableElement181this._register(onAnchoredSelectionChange(this.updateScrollDimensions, this));182this._register(this.onDidHeightChange(this.updateScrollDimensions, this));183} else {184this.input.type = this.options.type || 'text';185this.input.setAttribute('wrap', 'off');186}187188if (this.ariaLabel) {189this.input.setAttribute('aria-label', this.ariaLabel);190}191192if (this.placeholder && !this.options.showPlaceholderOnFocus) {193this.setPlaceHolder(this.placeholder);194}195196if (this.tooltip) {197this.setTooltip(this.tooltip);198}199200this.oninput(this.input, () => this.onValueChange());201this.onblur(this.input, () => this.onBlur());202this.onfocus(this.input, () => this.onFocus());203204this._register(this.ignoreGesture(this.input));205206setTimeout(() => this.updateMirror(), 0);207208// Support actions209if (this.options.actions) {210this.actionbar = this._register(new ActionBar(this.element, {211actionViewItemProvider: this.options.actionViewItemProvider212}));213this.actionbar.push(this.options.actions, { icon: true, label: false });214}215216this.applyStyles();217}218219public setActions(actions: ReadonlyArray<IAction> | undefined, actionViewItemProvider?: IActionViewItemProvider): void {220if (this.actionbar) {221this.actionbar.clear();222if (actions) {223this.actionbar.push(actions, { icon: true, label: false });224}225} else if (actions) {226this.actionbar = this._register(new ActionBar(this.element, {227actionViewItemProvider: actionViewItemProvider ?? this.options.actionViewItemProvider228}));229this.actionbar.push(actions, { icon: true, label: false });230}231}232233protected onBlur(): void {234this._hideMessage();235if (this.options.showPlaceholderOnFocus) {236this.input.setAttribute('placeholder', '');237}238}239240protected onFocus(): void {241this._showMessage();242if (this.options.showPlaceholderOnFocus) {243this.input.setAttribute('placeholder', this.placeholder || '');244}245}246247public setPlaceHolder(placeHolder: string): void {248this.placeholder = placeHolder;249this.input.setAttribute('placeholder', placeHolder);250}251252public setTooltip(tooltip: string): void {253this.tooltip = tooltip;254if (!this.hover.value) {255this.hover.value = this._register(getBaseLayerHoverDelegate().setupDelayedHoverAtMouse(this.input, () => ({256content: this.tooltip,257appearance: {258compact: true,259}260})));261}262}263264public setAriaLabel(label: string): void {265this.ariaLabel = label;266267if (label) {268this.input.setAttribute('aria-label', this.ariaLabel);269} else {270this.input.removeAttribute('aria-label');271}272}273274public getAriaLabel(): string {275return this.ariaLabel;276}277278public get mirrorElement(): HTMLElement | undefined {279return this.mirror;280}281282public get inputElement(): HTMLInputElement {283return this.input;284}285286public get value(): string {287return this.input.value;288}289290public set value(newValue: string) {291if (this.input.value !== newValue) {292this.input.value = newValue;293this.onValueChange();294}295}296297public get step(): string {298return this.input.step;299}300301public set step(newValue: string) {302this.input.step = newValue;303}304305public get height(): number {306return typeof this.cachedHeight === 'number' ? this.cachedHeight : dom.getTotalHeight(this.element);307}308309public focus(): void {310this.input.focus();311}312313public blur(): void {314this.input.blur();315}316317public hasFocus(): boolean {318return dom.isActiveElement(this.input);319}320321public select(range: IRange | null = null): void {322this.input.select();323324if (range) {325this.input.setSelectionRange(range.start, range.end);326if (range.end === this.input.value.length) {327this.input.scrollLeft = this.input.scrollWidth;328}329}330}331332public isSelectionAtEnd(): boolean {333return this.input.selectionEnd === this.input.value.length && this.input.selectionStart === this.input.selectionEnd;334}335336public getSelection(): IRange | null {337const selectionStart = this.input.selectionStart;338if (selectionStart === null) {339return null;340}341const selectionEnd = this.input.selectionEnd ?? selectionStart;342return {343start: selectionStart,344end: selectionEnd,345};346}347348public enable(): void {349this.input.removeAttribute('disabled');350}351352public disable(): void {353this.blur();354this.input.disabled = true;355this._hideMessage();356}357358public setEnabled(enabled: boolean): void {359if (enabled) {360this.enable();361} else {362this.disable();363}364}365366public get width(): number {367return dom.getTotalWidth(this.input);368}369370public set width(width: number) {371if (this.options.flexibleHeight && this.options.flexibleWidth) {372// textarea with horizontal scrolling373let horizontalPadding = 0;374if (this.mirror) {375const paddingLeft = parseFloat(this.mirror.style.paddingLeft || '') || 0;376const paddingRight = parseFloat(this.mirror.style.paddingRight || '') || 0;377horizontalPadding = paddingLeft + paddingRight;378}379this.input.style.width = (width - horizontalPadding) + 'px';380} else {381this.input.style.width = width + 'px';382}383384if (this.mirror) {385this.mirror.style.width = width + 'px';386}387}388389public set paddingRight(paddingRight: number) {390// Set width to avoid hint text overlapping buttons391this.input.style.width = `calc(100% - ${paddingRight}px)`;392393if (this.mirror) {394this.mirror.style.paddingRight = paddingRight + 'px';395}396}397398private updateScrollDimensions(): void {399if (typeof this.cachedContentHeight !== 'number' || typeof this.cachedHeight !== 'number' || !this.scrollableElement) {400return;401}402403const scrollHeight = this.cachedContentHeight;404const height = this.cachedHeight;405const scrollTop = this.input.scrollTop;406407this.scrollableElement.setScrollDimensions({ scrollHeight, height });408this.scrollableElement.setScrollPosition({ scrollTop });409}410411public showMessage(message: IMessage, force?: boolean): void {412if (this.state === 'open' && equals(this.message, message)) {413// Already showing414return;415}416417this.message = message;418419this.element.classList.remove('idle');420this.element.classList.remove('info');421this.element.classList.remove('warning');422this.element.classList.remove('error');423this.element.classList.add(this.classForType(message.type));424425const styles = this.stylesForType(this.message.type);426this.element.style.border = `1px solid ${cssJs.asCssValueWithDefault(styles.border, 'transparent')}`;427428if (this.message.content && (this.hasFocus() || force)) {429this._showMessage();430}431}432433public hideMessage(): void {434this.message = null;435436this.element.classList.remove('info');437this.element.classList.remove('warning');438this.element.classList.remove('error');439this.element.classList.add('idle');440441this._hideMessage();442this.applyStyles();443}444445public isInputValid(): boolean {446return !!this.validation && !this.validation(this.value);447}448449public validate(): MessageType | undefined {450let errorMsg: IMessage | null = null;451452if (this.validation) {453errorMsg = this.validation(this.value);454455if (errorMsg) {456this.inputElement.setAttribute('aria-invalid', 'true');457this.showMessage(errorMsg);458}459else if (this.inputElement.hasAttribute('aria-invalid')) {460this.inputElement.removeAttribute('aria-invalid');461this.hideMessage();462}463}464465return errorMsg?.type;466}467468public stylesForType(type: MessageType | undefined): { border: string | undefined; background: string | undefined; foreground: string | undefined } {469const styles = this.options.inputBoxStyles;470switch (type) {471case MessageType.INFO: return { border: styles.inputValidationInfoBorder, background: styles.inputValidationInfoBackground, foreground: styles.inputValidationInfoForeground };472case MessageType.WARNING: return { border: styles.inputValidationWarningBorder, background: styles.inputValidationWarningBackground, foreground: styles.inputValidationWarningForeground };473default: return { border: styles.inputValidationErrorBorder, background: styles.inputValidationErrorBackground, foreground: styles.inputValidationErrorForeground };474}475}476477private classForType(type: MessageType | undefined): string {478switch (type) {479case MessageType.INFO: return 'info';480case MessageType.WARNING: return 'warning';481default: return 'error';482}483}484485private _showMessage(): void {486if (!this.contextViewProvider || !this.message) {487return;488}489490let div: HTMLElement;491const layout = () => div.style.width = dom.getTotalWidth(this.element) + 'px';492493this.contextViewProvider.showContextView({494getAnchor: () => this.element,495anchorAlignment: AnchorAlignment.RIGHT,496render: (container: HTMLElement) => {497if (!this.message) {498return null;499}500501div = dom.append(container, $('.monaco-inputbox-container'));502layout();503504505const spanElement = $('span.monaco-inputbox-message');506if (this.message.formatContent) {507renderFormattedText(this.message.content!, undefined, spanElement);508} else {509renderText(this.message.content!, undefined, spanElement);510}511512spanElement.classList.add(this.classForType(this.message.type));513514const styles = this.stylesForType(this.message.type);515spanElement.style.backgroundColor = styles.background ?? '';516spanElement.style.color = styles.foreground ?? '';517spanElement.style.border = styles.border ? `1px solid ${styles.border}` : '';518519dom.append(div, spanElement);520521return null;522},523onHide: () => {524this.state = 'closed';525},526layout: layout527});528529// ARIA Support530let alertText: string;531if (this.message.type === MessageType.ERROR) {532alertText = nls.localize('alertErrorMessage', "Error: {0}", this.message.content);533} else if (this.message.type === MessageType.WARNING) {534alertText = nls.localize('alertWarningMessage', "Warning: {0}", this.message.content);535} else {536alertText = nls.localize('alertInfoMessage', "Info: {0}", this.message.content);537}538539aria.alert(alertText);540541this.state = 'open';542}543544private _hideMessage(): void {545if (!this.contextViewProvider) {546return;547}548549if (this.state === 'open') {550this.contextViewProvider.hideContextView();551}552553this.state = 'idle';554}555556private layoutMessage(): void {557if (this.state === 'open' && this.contextViewProvider) {558this.contextViewProvider.layout();559}560}561562private onValueChange(): void {563this._onDidChange.fire(this.value);564565this.validate();566this.updateMirror();567this.input.classList.toggle('empty', !this.value);568569if (this.state === 'open' && this.contextViewProvider) {570this.contextViewProvider.layout();571}572573if (this.options.hideHoverOnValueChange) {574getBaseLayerHoverDelegate().hideHover();575}576}577578private updateMirror(): void {579if (!this.mirror) {580return;581}582583const value = this.value;584const lastCharCode = value.charCodeAt(value.length - 1);585const suffix = lastCharCode === 10 ? ' ' : '';586const mirrorTextContent = (value + suffix)587.replace(/\u000c/g, ''); // Don't measure with the form feed character, which messes up sizing588589if (mirrorTextContent) {590this.mirror.textContent = value + suffix;591} else {592this.mirror.innerText = '\u00a0';593}594595this.layout();596}597598protected applyStyles(): void {599const styles = this.options.inputBoxStyles;600601const background = styles.inputBackground ?? '';602const foreground = styles.inputForeground ?? '';603const border = styles.inputBorder ?? '';604605this.element.style.backgroundColor = background;606this.element.style.color = foreground;607this.input.style.backgroundColor = 'inherit';608this.input.style.color = foreground;609610// there's always a border, even if the color is not set.611this.element.style.border = `1px solid ${cssJs.asCssValueWithDefault(border, 'transparent')}`;612}613614public layout(): void {615if (!this.mirror) {616this.layoutMessage();617return;618}619620const previousHeight = this.cachedContentHeight;621this.cachedContentHeight = dom.getTotalHeight(this.mirror);622623if (previousHeight !== this.cachedContentHeight) {624this.cachedHeight = Math.min(this.cachedContentHeight, this.maxHeight);625this.input.style.height = this.cachedHeight + 'px';626this._onDidHeightChange.fire(this.cachedContentHeight);627}628629this.layoutMessage();630}631632public insertAtCursor(text: string): void {633const inputElement = this.inputElement;634const start = inputElement.selectionStart;635const end = inputElement.selectionEnd;636const content = inputElement.value;637638if (start !== null && end !== null) {639this.value = content.substr(0, start) + text + content.substr(end);640inputElement.setSelectionRange(start + 1, start + 1);641this.layout();642}643}644645public override dispose(): void {646this._hideMessage();647648this.message = null;649650this.actionbar?.dispose();651652super.dispose();653}654}655656export interface IHistoryInputOptions extends IInputOptions {657readonly showHistoryHint?: () => boolean;658}659660export class HistoryInputBox extends InputBox implements IHistoryNavigationWidget {661662private readonly history: HistoryNavigator<string>;663private observer: MutationObserver | undefined;664665private readonly _onDidFocus = this._register(new Emitter<void>());666readonly onDidFocus = this._onDidFocus.event;667668private readonly _onDidBlur = this._register(new Emitter<void>());669readonly onDidBlur = this._onDidBlur.event;670671constructor(container: HTMLElement, contextViewProvider: IContextViewProvider | undefined, options: IHistoryInputOptions) {672const NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_NO_PARENS = nls.localize({673key: 'history.inputbox.hint.suffix.noparens',674comment: ['Text is the suffix of an input field placeholder coming after the action the input field performs, this will be used when the input field ends in a closing parenthesis ")", for example "Filter (e.g. text, !exclude)". The character inserted into the final string is \u21C5 to represent the up and down arrow keys.']675}, ' or {0} for history', `\u21C5`);676const NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_IN_PARENS = nls.localize({677key: 'history.inputbox.hint.suffix.inparens',678comment: ['Text is the suffix of an input field placeholder coming after the action the input field performs, this will be used when the input field does NOT end in a closing parenthesis (eg. "Find"). The character inserted into the final string is \u21C5 to represent the up and down arrow keys.']679}, ' ({0} for history)', `\u21C5`);680681super(container, contextViewProvider, options);682this.history = this._register(new HistoryNavigator<string>(options.history, 100));683684// Function to append the history suffix to the placeholder if necessary685const addSuffix = () => {686if (options.showHistoryHint && options.showHistoryHint() && !this.placeholder.endsWith(NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_NO_PARENS) && !this.placeholder.endsWith(NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_IN_PARENS) && this.history.getHistory().length) {687const suffix = this.placeholder.endsWith(')') ? NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_NO_PARENS : NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_IN_PARENS;688const suffixedPlaceholder = this.placeholder + suffix;689if (options.showPlaceholderOnFocus && !dom.isActiveElement(this.input)) {690this.placeholder = suffixedPlaceholder;691}692else {693this.setPlaceHolder(suffixedPlaceholder);694}695}696};697698// Spot the change to the textarea class attribute which occurs when it changes between non-empty and empty,699// and add the history suffix to the placeholder if not yet present700this.observer = new MutationObserver((mutationList: MutationRecord[], observer: MutationObserver) => {701mutationList.forEach((mutation: MutationRecord) => {702if (!mutation.target.textContent) {703addSuffix();704}705});706});707this.observer.observe(this.input, { attributeFilter: ['class'] });708709this.onfocus(this.input, () => addSuffix());710this.onblur(this.input, () => {711const resetPlaceholder = (historyHint: string) => {712if (!this.placeholder.endsWith(historyHint)) {713return false;714}715else {716const revertedPlaceholder = this.placeholder.slice(0, this.placeholder.length - historyHint.length);717if (options.showPlaceholderOnFocus) {718this.placeholder = revertedPlaceholder;719}720else {721this.setPlaceHolder(revertedPlaceholder);722}723return true;724}725};726if (!resetPlaceholder(NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_IN_PARENS)) {727resetPlaceholder(NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_NO_PARENS);728}729});730}731732override dispose() {733super.dispose();734if (this.observer) {735this.observer.disconnect();736this.observer = undefined;737}738}739740public addToHistory(always?: boolean): void {741if (this.value && (always || this.value !== this.getCurrentValue())) {742this.history.add(this.value);743}744}745746public prependHistory(restoredHistory: string[]): void {747const newHistory = this.getHistory();748this.clearHistory();749750restoredHistory.forEach((item) => {751this.history.add(item);752});753754newHistory.forEach(item => {755this.history.add(item);756});757}758759public getHistory(): string[] {760return this.history.getHistory();761}762763public isAtFirstInHistory(): boolean {764return this.history.isFirst();765}766767public isAtLastInHistory(): boolean {768return this.history.isLast();769}770771public isNowhereInHistory(): boolean {772return this.history.isNowhere();773}774775public showNextValue(): void {776if (!this.history.has(this.value)) {777this.addToHistory();778}779780let next = this.getNextValue();781if (next) {782next = next === this.value ? this.getNextValue() : next;783}784785this.value = next ?? '';786aria.status(this.value ? this.value : nls.localize('clearedInput', "Cleared Input"));787}788789public showPreviousValue(): void {790if (!this.history.has(this.value)) {791this.addToHistory();792}793794let previous = this.getPreviousValue();795if (previous) {796previous = previous === this.value ? this.getPreviousValue() : previous;797}798799if (previous) {800this.value = previous;801aria.status(this.value);802}803}804805public clearHistory(): void {806this.history.clear();807}808809public override setPlaceHolder(placeHolder: string): void {810super.setPlaceHolder(placeHolder);811this.setTooltip(placeHolder);812}813814protected override onBlur(): void {815super.onBlur();816this._onDidBlur.fire();817}818819protected override onFocus(): void {820super.onFocus();821this._onDidFocus.fire();822}823824private getCurrentValue(): string | null {825let currentValue = this.history.current();826if (!currentValue) {827currentValue = this.history.last();828this.history.next();829}830return currentValue;831}832833private getPreviousValue(): string | null {834return this.history.previous() || this.history.first();835}836837private getNextValue(): string | null {838return this.history.next();839}840}841842843