Path: blob/main/src/vs/base/browser/ui/toggle/toggle.ts
5221 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 { IAction } from '../../../common/actions.js';6import { Codicon } from '../../../common/codicons.js';7import { Emitter, Event } from '../../../common/event.js';8import { IMarkdownString, isMarkdownString } from '../../../common/htmlContent.js';9import { getCodiconAriaLabel, stripIcons } from '../../../common/iconLabels.js';10import { KeyCode } from '../../../common/keyCodes.js';11import { ThemeIcon } from '../../../common/themables.js';12import { $, addDisposableListener, EventType, isActiveElement, isHTMLElement } from '../../dom.js';13import { IKeyboardEvent } from '../../keyboardEvent.js';14import { BaseActionViewItem, IActionViewItemOptions } from '../actionbar/actionViewItems.js';15import { IActionViewItemProvider } from '../actionbar/actionbar.js';16import { HoverStyle, IHoverLifecycleOptions } from '../hover/hover.js';17import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js';18import { Widget } from '../widget.js';19import './toggle.css';2021export interface IToggleOpts extends IToggleStyles {22readonly actionClassName?: string;23readonly icon?: ThemeIcon;24readonly title: string | IMarkdownString | HTMLElement;25readonly isChecked: boolean;26readonly notFocusable?: boolean;27readonly hoverLifecycleOptions?: IHoverLifecycleOptions;28}2930export interface IToggleStyles {31readonly inputActiveOptionBorder: string | undefined;32readonly inputActiveOptionForeground: string | undefined;33readonly inputActiveOptionBackground: string | undefined;34}3536export interface ICheckboxStyles {37readonly checkboxBackground: string | undefined;38readonly checkboxBorder: string | undefined;39readonly checkboxForeground: string | undefined;40readonly checkboxDisabledBackground: string | undefined;41readonly checkboxDisabledForeground: string | undefined;42readonly size?: number;43readonly hoverLifecycleOptions?: IHoverLifecycleOptions;44}4546export const unthemedToggleStyles = {47inputActiveOptionBorder: '#007ACC00',48inputActiveOptionForeground: '#FFFFFF',49inputActiveOptionBackground: '#0E639C50'50};5152export class ToggleActionViewItem extends BaseActionViewItem {5354protected readonly toggle: Toggle;5556constructor(context: unknown, action: IAction, options: IActionViewItemOptions) {57super(context, action, options);5859const title = (<IActionViewItemOptions>this.options).keybinding ?60`${this._action.label} (${(<IActionViewItemOptions>this.options).keybinding})` : this._action.label;61this.toggle = this._register(new Toggle({62actionClassName: this._action.class,63isChecked: !!this._action.checked,64title,65notFocusable: true,66inputActiveOptionBackground: options.toggleStyles?.inputActiveOptionBackground,67inputActiveOptionBorder: options.toggleStyles?.inputActiveOptionBorder,68inputActiveOptionForeground: options.toggleStyles?.inputActiveOptionForeground,69}));70this._register(this.toggle.onChange(() => {71this._action.checked = !!this.toggle && this.toggle.checked;72}));73}7475override render(container: HTMLElement): void {76this.element = container;77this.element.appendChild(this.toggle.domNode);7879this.updateChecked();80this.updateEnabled();81}8283protected override updateEnabled(): void {84if (this.toggle) {85if (this.isEnabled()) {86this.toggle.enable();87this.element?.classList.remove('disabled');88} else {89this.toggle.disable();90this.element?.classList.add('disabled');91}92}93}9495protected override updateChecked(): void {96this.toggle.checked = !!this._action.checked;97}9899protected override updateLabel(): void {100const title = (<IActionViewItemOptions>this.options).keybinding ?101`${this._action.label} (${(<IActionViewItemOptions>this.options).keybinding})` : this._action.label;102this.toggle.setTitle(title);103}104105override focus(): void {106this.toggle.domNode.tabIndex = 0;107this.toggle.focus();108}109110override blur(): void {111this.toggle.domNode.tabIndex = -1;112this.toggle.domNode.blur();113}114115override setFocusable(focusable: boolean): void {116this.toggle.domNode.tabIndex = focusable ? 0 : -1;117}118119}120121export class Toggle extends Widget {122123private readonly _onChange = this._register(new Emitter<boolean>());124get onChange(): Event<boolean /* via keyboard */> { return this._onChange.event; }125126private readonly _onKeyDown = this._register(new Emitter<IKeyboardEvent>());127get onKeyDown(): Event<IKeyboardEvent> { return this._onKeyDown.event; }128129private readonly _opts: IToggleOpts;130private _title: string | IMarkdownString | HTMLElement;131private _icon: ThemeIcon | undefined;132readonly domNode: HTMLElement;133134private _checked: boolean;135136constructor(opts: IToggleOpts) {137super();138139this._opts = opts;140this._title = this._opts.title;141this._checked = this._opts.isChecked;142143const classes = ['monaco-custom-toggle'];144if (this._opts.icon) {145this._icon = this._opts.icon;146classes.push(...ThemeIcon.asClassNameArray(this._icon));147}148if (this._opts.actionClassName) {149classes.push(...this._opts.actionClassName.split(' '));150}151if (this._checked) {152classes.push('checked');153}154155this.domNode = document.createElement('div');156this._register(getBaseLayerHoverDelegate().setupDelayedHover(this.domNode, () => ({157content: !isMarkdownString(this._title) && !isHTMLElement(this._title) ? stripIcons(this._title) : this._title,158style: HoverStyle.Pointer,159}), this._opts.hoverLifecycleOptions));160this.domNode.classList.add(...classes);161if (!this._opts.notFocusable) {162this.domNode.tabIndex = 0;163}164this.domNode.setAttribute('role', 'checkbox');165this.domNode.setAttribute('aria-checked', String(this._checked));166167this.setTitle(this._opts.title);168this.applyStyles();169170this.onclick(this.domNode, (ev) => {171if (this.enabled) {172this.checked = !this._checked;173this._onChange.fire(false);174ev.preventDefault();175ev.stopPropagation();176}177});178179this._register(this.ignoreGesture(this.domNode));180181this.onkeydown(this.domNode, (keyboardEvent) => {182if (!this.enabled) {183return;184}185186if (keyboardEvent.keyCode === KeyCode.Space || keyboardEvent.keyCode === KeyCode.Enter) {187this.checked = !this._checked;188this._onChange.fire(true);189keyboardEvent.preventDefault();190keyboardEvent.stopPropagation();191return;192}193194this._onKeyDown.fire(keyboardEvent);195});196}197198get enabled(): boolean {199return this.domNode.getAttribute('aria-disabled') !== 'true';200}201202focus(): void {203this.domNode.focus();204}205206get checked(): boolean {207return this._checked;208}209210set checked(newIsChecked: boolean) {211this._checked = newIsChecked;212213this.domNode.setAttribute('aria-checked', String(this._checked));214this.domNode.classList.toggle('checked', this._checked);215216this.applyStyles();217}218219setIcon(icon: ThemeIcon | undefined): void {220if (this._icon) {221this.domNode.classList.remove(...ThemeIcon.asClassNameArray(this._icon));222}223this._icon = icon;224if (this._icon) {225this.domNode.classList.add(...ThemeIcon.asClassNameArray(this._icon));226}227}228229width(): number {230return 2 /*margin left*/ + 2 /*border*/ + 2 /*padding*/ + 16 /* icon width */;231}232233protected applyStyles(): void {234if (this.domNode) {235this.domNode.style.borderColor = (this._checked && this._opts.inputActiveOptionBorder) || '';236this.domNode.style.color = (this._checked && this._opts.inputActiveOptionForeground) || 'inherit';237this.domNode.style.backgroundColor = (this._checked && this._opts.inputActiveOptionBackground) || '';238}239}240241enable(): void {242this.domNode.setAttribute('aria-disabled', String(false));243this.domNode.classList.remove('disabled');244}245246disable(): void {247this.domNode.setAttribute('aria-disabled', String(true));248this.domNode.classList.add('disabled');249}250251setTitle(newTitle: string | IMarkdownString | HTMLElement): void {252this._title = newTitle;253254const ariaLabel = typeof newTitle === 'string' ? newTitle : isMarkdownString(newTitle) ? newTitle.value : newTitle.textContent;255256this.domNode.setAttribute('aria-label', getCodiconAriaLabel(ariaLabel));257}258259set visible(visible: boolean) {260this.domNode.style.display = visible ? '' : 'none';261}262263get visible() {264return this.domNode.style.display !== 'none';265}266}267268269abstract class BaseCheckbox extends Widget {270static readonly CLASS_NAME = 'monaco-checkbox';271272protected readonly _onChange = this._register(new Emitter<boolean>());273readonly onChange: Event<boolean /* via keyboard */> = this._onChange.event;274275constructor(276protected readonly checkbox: Toggle,277readonly domNode: HTMLElement,278protected readonly styles: ICheckboxStyles279) {280super();281282this.applyStyles();283}284285get enabled(): boolean {286return this.checkbox.enabled;287}288289focus(): void {290this.domNode.focus();291}292293hasFocus(): boolean {294return isActiveElement(this.domNode);295}296297enable(): void {298this.checkbox.enable();299this.applyStyles(true);300}301302disable(): void {303this.checkbox.disable();304this.applyStyles(false);305}306307setTitle(newTitle: string): void {308this.checkbox.setTitle(newTitle);309}310311protected applyStyles(enabled = this.enabled): void {312this.domNode.style.color = (enabled ? this.styles.checkboxForeground : this.styles.checkboxDisabledForeground) || '';313this.domNode.style.backgroundColor = (enabled ? this.styles.checkboxBackground : this.styles.checkboxDisabledBackground) || '';314this.domNode.style.borderColor = (enabled ? this.styles.checkboxBorder : this.styles.checkboxDisabledBackground) || '';315316const size = this.styles.size || 18;317this.domNode.style.width =318this.domNode.style.height =319this.domNode.style.fontSize = `${size}px`;320this.domNode.style.fontSize = `${size - 2}px`;321}322}323324export class Checkbox extends BaseCheckbox {325constructor(title: string, isChecked: boolean, styles: ICheckboxStyles) {326const toggle = new Toggle({ title, isChecked, icon: Codicon.check, actionClassName: BaseCheckbox.CLASS_NAME, hoverLifecycleOptions: styles.hoverLifecycleOptions, ...unthemedToggleStyles });327super(toggle, toggle.domNode, styles);328329this._register(toggle);330this._register(this.checkbox.onChange(keyboard => {331this.applyStyles();332this._onChange.fire(keyboard);333}));334}335336get checked(): boolean {337return this.checkbox.checked;338}339340set checked(newIsChecked: boolean) {341this.checkbox.checked = newIsChecked;342this.applyStyles();343}344345protected override applyStyles(enabled?: boolean): void {346if (this.checkbox.checked) {347this.checkbox.setIcon(Codicon.check);348} else {349this.checkbox.setIcon(undefined);350}351super.applyStyles(enabled);352}353}354355export class TriStateCheckbox extends BaseCheckbox {356constructor(357title: string,358private _state: boolean | 'mixed',359styles: ICheckboxStyles360) {361let icon: ThemeIcon | undefined;362switch (_state) {363case true:364icon = Codicon.check;365break;366case 'mixed':367icon = Codicon.dash;368break;369case false:370icon = undefined;371break;372}373const checkbox = new Toggle({374title,375isChecked: _state === true,376icon,377actionClassName: Checkbox.CLASS_NAME,378hoverLifecycleOptions: styles.hoverLifecycleOptions,379...unthemedToggleStyles380});381super(382checkbox,383checkbox.domNode,384styles385);386387this._register(checkbox);388this._register(this.checkbox.onChange(keyboard => {389this._state = this.checkbox.checked;390this.applyStyles();391this._onChange.fire(keyboard);392}));393}394395get checked(): boolean | 'mixed' {396return this._state;397}398399set checked(newState: boolean | 'mixed') {400if (this._state !== newState) {401this._state = newState;402this.checkbox.checked = newState === true;403this.applyStyles();404}405}406407protected override applyStyles(enabled?: boolean): void {408switch (this._state) {409case true:410this.checkbox.setIcon(Codicon.check);411break;412case 'mixed':413this.checkbox.setIcon(Codicon.dash);414break;415case false:416this.checkbox.setIcon(undefined);417break;418}419super.applyStyles(enabled);420}421}422423export interface ICheckboxActionViewItemOptions extends IActionViewItemOptions {424checkboxStyles: ICheckboxStyles;425}426427export class CheckboxActionViewItem extends BaseActionViewItem {428429protected readonly toggle: Checkbox;430private cssClass?: string;431432constructor(context: unknown, action: IAction, options: ICheckboxActionViewItemOptions) {433super(context, action, options);434435this.toggle = this._register(new Checkbox(this._action.label, !!this._action.checked, options.checkboxStyles));436this._register(this.toggle.onChange(() => this.onChange()));437}438439override render(container: HTMLElement): void {440this.element = container;441this.element.classList.add('checkbox-action-item');442this.element.appendChild(this.toggle.domNode);443if ((<IActionViewItemOptions>this.options).label && this._action.label) {444const label = this.element.appendChild($('span.checkbox-label', undefined, this._action.label));445this._register(addDisposableListener(label, EventType.CLICK, (e: MouseEvent) => {446this.toggle.checked = !this.toggle.checked;447e.stopPropagation();448e.preventDefault();449this.onChange();450}));451}452453this.updateEnabled();454this.updateClass();455this.updateChecked();456}457458private onChange(): void {459this._action.checked = !!this.toggle && this.toggle.checked;460this.actionRunner.run(this._action, this._context);461}462463protected override updateEnabled(): void {464if (this.isEnabled()) {465this.toggle.enable();466} else {467this.toggle.disable();468}469if (this.action.enabled) {470this.element?.classList.remove('disabled');471} else {472this.element?.classList.add('disabled');473}474}475476protected override updateChecked(): void {477this.toggle.checked = !!this._action.checked;478}479480protected override updateClass(): void {481if (this.cssClass) {482this.toggle.domNode.classList.remove(...this.cssClass.split(' '));483}484this.cssClass = this.getClass();485if (this.cssClass) {486this.toggle.domNode.classList.add(...this.cssClass.split(' '));487}488}489490override focus(): void {491this.toggle.domNode.tabIndex = 0;492this.toggle.focus();493}494495override blur(): void {496this.toggle.domNode.tabIndex = -1;497this.toggle.domNode.blur();498}499500override setFocusable(focusable: boolean): void {501this.toggle.domNode.tabIndex = focusable ? 0 : -1;502}503504}505506/**507* Creates an action view item provider that renders toggles for actions with a checked state508* and falls back to default button rendering for regular actions.509*510* @param toggleStyles - Optional styles to apply to toggle items511* @returns An IActionViewItemProvider that can be used with ActionBar512*/513export function createToggleActionViewItemProvider(toggleStyles?: IToggleStyles): IActionViewItemProvider {514return (action: IAction, options: IActionViewItemOptions) => {515// Only render as a toggle if the action has a checked property516if (action.checked !== undefined) {517return new ToggleActionViewItem(null, action, { ...options, toggleStyles });518}519// Return undefined to fall back to default button rendering520return undefined;521};522}523524525