Path: blob/main/src/vs/base/browser/ui/actionbar/actionViewItems.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 { isFirefox } from '../../browser.js';6import { DataTransfers } from '../../dnd.js';7import { addDisposableListener, EventHelper, EventLike, EventType } from '../../dom.js';8import { EventType as TouchEventType, Gesture } from '../../touch.js';9import { IActionViewItem } from './actionbar.js';10import { IContextViewProvider } from '../contextview/contextview.js';11import { getDefaultHoverDelegate } from '../hover/hoverDelegateFactory.js';12import { IHoverDelegate } from '../hover/hoverDelegate.js';13import { ISelectBoxOptions, ISelectBoxStyles, ISelectOptionItem, SelectBox } from '../selectBox/selectBox.js';14import { IToggleStyles } from '../toggle/toggle.js';15import { Action, ActionRunner, IAction, IActionChangeEvent, IActionRunner, Separator } from '../../../common/actions.js';16import { Disposable } from '../../../common/lifecycle.js';17import * as platform from '../../../common/platform.js';18import * as types from '../../../common/types.js';19import './actionbar.css';20import * as nls from '../../../../nls.js';21import type { IManagedHover, IManagedHoverContent } from '../hover/hover.js';22import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js';2324export interface IBaseActionViewItemOptions {25readonly draggable?: boolean;26readonly isMenu?: boolean;27readonly isTabList?: boolean;28readonly useEventAsContext?: boolean;29readonly hoverDelegate?: IHoverDelegate;30}3132export class BaseActionViewItem extends Disposable implements IActionViewItem {3334element: HTMLElement | undefined;3536_context: unknown;37readonly _action: IAction;3839private customHover?: IManagedHover;4041get action() {42return this._action;43}4445private _actionRunner: IActionRunner | undefined;4647constructor(48context: unknown,49action: IAction,50protected readonly options: IBaseActionViewItemOptions = {}51) {52super();5354this._context = context || this;55this._action = action;5657if (action instanceof Action) {58this._register(action.onDidChange(event => {59if (!this.element) {60// we have not been rendered yet, so there61// is no point in updating the UI62return;63}6465this.handleActionChangeEvent(event);66}));67}68}6970private handleActionChangeEvent(event: IActionChangeEvent): void {71if (event.enabled !== undefined) {72this.updateEnabled();73}7475if (event.checked !== undefined) {76this.updateChecked();77}7879if (event.class !== undefined) {80this.updateClass();81}8283if (event.label !== undefined) {84this.updateLabel();85this.updateTooltip();86}8788if (event.tooltip !== undefined) {89this.updateTooltip();90}91}9293get actionRunner(): IActionRunner {94if (!this._actionRunner) {95this._actionRunner = this._register(new ActionRunner());96}9798return this._actionRunner;99}100101set actionRunner(actionRunner: IActionRunner) {102this._actionRunner = actionRunner;103}104105isEnabled(): boolean {106return this._action.enabled;107}108109setActionContext(newContext: unknown): void {110this._context = newContext;111}112113render(container: HTMLElement): void {114const element = this.element = container;115this._register(Gesture.addTarget(container));116117const enableDragging = this.options && this.options.draggable;118if (enableDragging) {119container.draggable = true;120121if (isFirefox) {122// Firefox: requires to set a text data transfer to get going123this._register(addDisposableListener(container, EventType.DRAG_START, e => e.dataTransfer?.setData(DataTransfers.TEXT, this._action.label)));124}125}126127this._register(addDisposableListener(element, TouchEventType.Tap, e => this.onClick(e, true))); // Preserve focus on tap #125470128129this._register(addDisposableListener(element, EventType.MOUSE_DOWN, e => {130if (!enableDragging) {131EventHelper.stop(e, true); // do not run when dragging is on because that would disable it132}133134if (this._action.enabled && e.button === 0) {135element.classList.add('active');136}137}));138139if (platform.isMacintosh) {140// macOS: allow to trigger the button when holding Ctrl+key and pressing the141// main mouse button. This is for scenarios where e.g. some interaction forces142// the Ctrl+key to be pressed and hold but the user still wants to interact143// with the actions (for example quick access in quick navigation mode).144this._register(addDisposableListener(element, EventType.CONTEXT_MENU, e => {145if (e.button === 0 && e.ctrlKey === true) {146this.onClick(e);147}148}));149}150151this._register(addDisposableListener(element, EventType.CLICK, e => {152EventHelper.stop(e, true);153154// menus do not use the click event155if (!(this.options && this.options.isMenu)) {156this.onClick(e);157}158}));159160this._register(addDisposableListener(element, EventType.DBLCLICK, e => {161EventHelper.stop(e, true);162}));163164[EventType.MOUSE_UP, EventType.MOUSE_OUT].forEach(event => {165this._register(addDisposableListener(element, event, e => {166EventHelper.stop(e);167element.classList.remove('active');168}));169});170}171172onClick(event: EventLike, preserveFocus = false): void {173EventHelper.stop(event, true);174175const context = types.isUndefinedOrNull(this._context) ? this.options?.useEventAsContext ? event : { preserveFocus } : this._context;176this.actionRunner.run(this._action, context);177}178179// Only set the tabIndex on the element once it is about to get focused180// That way this element wont be a tab stop when it is not needed #106441181focus(): void {182if (this.element) {183this.element.tabIndex = 0;184this.element.focus();185this.element.classList.add('focused');186}187}188189isFocused(): boolean {190return !!this.element?.classList.contains('focused');191}192193blur(): void {194if (this.element) {195this.element.blur();196this.element.tabIndex = -1;197this.element.classList.remove('focused');198}199}200201setFocusable(focusable: boolean): void {202if (this.element) {203this.element.tabIndex = focusable ? 0 : -1;204}205}206207get trapsArrowNavigation(): boolean {208return false;209}210211protected updateEnabled(): void {212// implement in subclass213}214215protected updateLabel(): void {216// implement in subclass217}218219protected getClass(): string | undefined {220return this.action.class;221}222223protected getTooltip(): string | undefined {224return this.action.tooltip;225}226227protected getHoverContents(): IManagedHoverContent | undefined {228return this.getTooltip();229}230231protected updateTooltip(): void {232if (!this.element) {233return;234}235const title = this.getHoverContents() ?? '';236this.updateAriaLabel();237238if (!this.customHover && title !== '') {239const hoverDelegate = this.options.hoverDelegate ?? getDefaultHoverDelegate('element');240this.customHover = this._store.add(getBaseLayerHoverDelegate().setupManagedHover(hoverDelegate, this.element, title));241} else if (this.customHover) {242this.customHover.update(title);243}244}245246protected updateAriaLabel(): void {247if (this.element) {248const title = this.getTooltip() ?? '';249this.element.setAttribute('aria-label', title);250}251}252253protected updateClass(): void {254// implement in subclass255}256257protected updateChecked(): void {258// implement in subclass259}260261override dispose(): void {262if (this.element) {263this.element.remove();264this.element = undefined;265}266this._context = undefined;267super.dispose();268}269}270271export interface IActionViewItemOptions extends IBaseActionViewItemOptions {272icon?: boolean;273label?: boolean;274readonly keybinding?: string | null;275readonly keybindingNotRenderedWithLabel?: boolean;276readonly toggleStyles?: IToggleStyles;277}278279export class ActionViewItem extends BaseActionViewItem {280281protected label: HTMLElement | undefined;282protected override readonly options: IActionViewItemOptions;283284private cssClass?: string;285286constructor(context: unknown, action: IAction, options: IActionViewItemOptions) {287options = {288...options,289icon: options.icon !== undefined ? options.icon : false,290label: options.label !== undefined ? options.label : true,291};292super(context, action, options);293294this.options = options;295this.cssClass = '';296}297298override render(container: HTMLElement): void {299super.render(container);300types.assertType(this.element);301302const label = document.createElement('a');303label.classList.add('action-label');304label.setAttribute('role', this.getDefaultAriaRole());305306this.label = label;307this.element.appendChild(label);308309if (this.options.label && this.options.keybinding && !this.options.keybindingNotRenderedWithLabel) {310const kbLabel = document.createElement('span');311kbLabel.classList.add('keybinding');312kbLabel.textContent = this.options.keybinding;313this.element.appendChild(kbLabel);314}315316this.updateClass();317this.updateLabel();318this.updateTooltip();319this.updateEnabled();320this.updateChecked();321}322323private getDefaultAriaRole(): 'presentation' | 'menuitem' | 'tab' | 'button' {324if (this._action.id === Separator.ID) {325return 'presentation'; // A separator is a presentation item326} else {327if (this.options.isMenu) {328return 'menuitem';329} else if (this.options.isTabList) {330return 'tab';331} else {332return 'button';333}334}335}336337// Only set the tabIndex on the element once it is about to get focused338// That way this element wont be a tab stop when it is not needed #106441339override focus(): void {340if (this.label) {341this.label.tabIndex = 0;342this.label.focus();343}344}345346override isFocused(): boolean {347return !!this.label && this.label?.tabIndex === 0;348}349350override blur(): void {351if (this.label) {352this.label.tabIndex = -1;353}354}355356override setFocusable(focusable: boolean): void {357if (this.label) {358this.label.tabIndex = focusable ? 0 : -1;359}360}361362protected override updateLabel(): void {363if (this.options.label && this.label) {364this.label.textContent = this.action.label;365}366}367368protected override getTooltip() {369let title: string | null = null;370371if (this.action.tooltip) {372title = this.action.tooltip;373374} else if (this.action.label) {375title = this.action.label;376if (this.options.keybinding) {377title = nls.localize({ key: 'titleLabel', comment: ['action title', 'action keybinding'] }, "{0} ({1})", title, this.options.keybinding);378}379}380return title ?? undefined;381}382383protected override updateClass(): void {384if (this.cssClass && this.label) {385this.label.classList.remove(...this.cssClass.split(' '));386}387if (this.options.icon) {388this.cssClass = this.getClass();389390if (this.label) {391this.label.classList.add('codicon');392if (this.cssClass) {393this.label.classList.add(...this.cssClass.split(' '));394}395}396397this.updateEnabled();398} else {399this.label?.classList.remove('codicon');400}401}402403protected override updateEnabled(): void {404if (this.action.enabled) {405if (this.label) {406this.label.removeAttribute('aria-disabled');407this.label.classList.remove('disabled');408}409410this.element?.classList.remove('disabled');411} else {412if (this.label) {413this.label.setAttribute('aria-disabled', 'true');414this.label.classList.add('disabled');415}416417this.element?.classList.add('disabled');418}419}420421protected override updateAriaLabel(): void {422if (this.label) {423const title = this.getTooltip() ?? '';424this.label.setAttribute('aria-label', title);425}426}427428protected override updateChecked(): void {429if (this.label) {430if (this.action.checked !== undefined) {431this.label.classList.toggle('checked', this.action.checked);432if (this.options.isTabList) {433this.label.setAttribute('aria-selected', this.action.checked ? 'true' : 'false');434} else {435this.label.setAttribute('aria-checked', this.action.checked ? 'true' : 'false');436this.label.setAttribute('role', 'checkbox');437}438} else {439this.label.classList.remove('checked');440this.label.removeAttribute(this.options.isTabList ? 'aria-selected' : 'aria-checked');441this.label.setAttribute('role', this.getDefaultAriaRole());442}443}444}445}446447export class SelectActionViewItem<T = string> extends BaseActionViewItem {448protected selectBox: SelectBox;449450constructor(ctx: unknown, action: IAction, options: ISelectOptionItem[], selected: number, contextViewProvider: IContextViewProvider, styles: ISelectBoxStyles, selectBoxOptions?: ISelectBoxOptions) {451super(ctx, action);452453this.selectBox = new SelectBox(options, selected, contextViewProvider, styles, selectBoxOptions);454this.selectBox.setFocusable(false);455456this._register(this.selectBox);457this.registerListeners();458}459460setOptions(options: ISelectOptionItem[], selected?: number): void {461this.selectBox.setOptions(options, selected);462}463464select(index: number): void {465this.selectBox.select(index);466}467468private registerListeners(): void {469this._register(this.selectBox.onDidSelect(e => this.runAction(e.selected, e.index)));470}471472protected runAction(option: string, index: number): void {473this.actionRunner.run(this._action, this.getActionContext(option, index));474}475476protected getActionContext(option: string, index: number): T | string {477return option;478}479480override setFocusable(focusable: boolean): void {481this.selectBox.setFocusable(focusable);482}483484override focus(): void {485this.selectBox?.focus();486}487488override blur(): void {489this.selectBox?.blur();490}491492override render(container: HTMLElement): void {493this.selectBox.render(container);494}495}496497498