diff --git a/extensions/dacpac/src/test/testContext.ts b/extensions/dacpac/src/test/testContext.ts index 6e3996dc6a..0d939e584d 100644 --- a/extensions/dacpac/src/test/testContext.ts +++ b/extensions/dacpac/src/test/testContext.ts @@ -311,7 +311,8 @@ export function createViewContext(): ViewTestContext { tabbedPanel: undefined!, separator: undefined!, propertiesContainer: undefined!, - infoBox: undefined! + infoBox: undefined!, + slider: undefined! } }; return { diff --git a/extensions/machine-learning/src/test/views/utils.ts b/extensions/machine-learning/src/test/views/utils.ts index b4d8b61e64..b9fadfa8f8 100644 --- a/extensions/machine-learning/src/test/views/utils.ts +++ b/extensions/machine-learning/src/test/views/utils.ts @@ -262,7 +262,8 @@ export function createViewContext(): ViewTestContext { tabbedPanel: undefined!, separator: undefined!, propertiesContainer: undefined!, - infoBox: undefined! + infoBox: undefined!, + slider: undefined! } }; let tab: azdata.window.DialogTab = { diff --git a/extensions/notebook/src/test/managePackages/managePackagesDialog.test.ts b/extensions/notebook/src/test/managePackages/managePackagesDialog.test.ts index f4f4d5d457..b1f587d866 100644 --- a/extensions/notebook/src/test/managePackages/managePackagesDialog.test.ts +++ b/extensions/notebook/src/test/managePackages/managePackagesDialog.test.ts @@ -301,7 +301,8 @@ describe('Manage Package Dialog', () => { tabbedPanel: undefined!, separator: undefined!, propertiesContainer: undefined!, - infoBox: undefined! + infoBox: undefined!, + slider: undefined! } }; diff --git a/extensions/schema-compare/src/test/testContext.ts b/extensions/schema-compare/src/test/testContext.ts index fb2f60b2cd..12e4cf7455 100644 --- a/extensions/schema-compare/src/test/testContext.ts +++ b/extensions/schema-compare/src/test/testContext.ts @@ -353,7 +353,8 @@ export function createViewContext(): ViewTestContext { tabbedPanel: undefined!, separator: undefined!, propertiesContainer: undefined!, - infoBox: undefined! + infoBox: undefined!, + slider: undefined! } }; return { diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index f234b4d326..1eaad12c83 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -345,6 +345,7 @@ declare module 'azdata' { separator(): ComponentBuilder; propertiesContainer(): ComponentBuilder; infoBox(): ComponentBuilder; + slider(): ComponentBuilder; } export interface ComponentBuilder { @@ -588,6 +589,38 @@ declare module 'azdata' { required?: boolean; } + export interface SliderComponentProperties extends ComponentProperties { + /** + * The value selected on the slider. Default initial value is the minimum value. + */ + value?: number, + /** + * The minimum value of the slider. Default value is 1. + */ + min?: number, + /** + * The maximum value of the slider. Default value is 100. + */ + max?: number, + /** + * The value between each "tick" of the slider. Default is 1. + */ + step?: number, + /** + * Whether to show the tick marks on the slider. Default is false. + */ + showTicks?: boolean + /** + * The width of the slider, not including the value box. + */ + width?: number | string; + } + + export interface SliderComponent extends Component, SliderComponentProperties { + onChanged: vscode.Event; + onInput: vscode.Event; + } + /** * A property to be displayed in the PropertiesContainerComponent */ diff --git a/src/sql/base/browser/ui/slider/slider.ts b/src/sql/base/browser/ui/slider/slider.ts new file mode 100644 index 0000000000..4b7628a001 --- /dev/null +++ b/src/sql/base/browser/ui/slider/slider.ts @@ -0,0 +1,238 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event, Emitter } from 'vs/base/common/event'; +import { Widget } from 'vs/base/browser/ui/widget'; + +export interface ISliderOptions { + /** + * The value selected on the slider. Default initial value is the minimum value. + */ + value?: number, + /** + * The minimum value of the slider. Default value is 1. + */ + min?: number, + /** + * The maximum value of the slider. Default value is 100. + */ + max?: number, + /** + * The value between each "tick" of the slider. Default is 1. + */ + step?: number, + /** + * Whether to show the tick marks on the slider. Default is false. + */ + showTicks?: boolean + /** + * The width of the slider, not including the value box. + */ + width?: number | string; + /** + * Whether the control is enabled or not. + */ + enabled?: boolean; + /** + * Callback called whenever the user stops dragging the slider. + */ + onChange?: (val: number) => void; + /** + * Callback called whenever the value of the slider changes while being dragged. + */ + onInput?: (val: number) => void; + /** + * The aria label to apply to the element for screen readers. + */ + ariaLabel?: string; +} + +/** + * Counter to user for creating unique datalist IDs for the displayed ticks + */ +let TICKS_DATALIST_ID = 1; + +export const DEFAULT_MIN = '1'; +export const DEFAULT_MAX = '100'; +export const DEFAULT_STEP = '1'; + +export class Slider extends Widget { + private _el: HTMLInputElement; + private _datalist: HTMLDataListElement | undefined = undefined; + private _showTicks: boolean = false; + + private _onChange = new Emitter(); + /** + * Event that is fired every time the user stops dragging the slider. + * Value is the current value of the slider. + */ + public readonly onChange: Event = this._onChange.event; + + private _onInput = new Emitter(); + /** + * Event that is fires every time the value changes while the user is + * dragging the slider. Value is the current value of the slider. + */ + public readonly onInput: Event = this._onInput.event; + + constructor(private _container: HTMLElement, opts: ISliderOptions) { + super(); + + this._el = document.createElement('input'); + this._el.type = 'range'; + this.width = opts.width?.toString() || ''; + this.step = opts.step; + this.min = opts.min; + this.max = opts.max; + this.value = opts.value; + this._showTicks = opts.showTicks; + + this.updateTicksDisplay(); + + const flexContainer = document.createElement('div'); + flexContainer.style.display = 'flex'; + flexContainer.style.flexFlow = 'row'; + + const valueBox = document.createElement('input'); + valueBox.type = 'text'; + valueBox.disabled = true; + valueBox.value = this.value.toString(); + valueBox.style.textAlign = 'center'; + valueBox.style.width = '40px'; + + if (opts.ariaLabel) { + this.ariaLabel = opts.ariaLabel; + } + + this.onchange(this._el, () => { + this._onChange.fire(this.value); + }); + + this.oninput(this._el, () => { + valueBox.value = this.value.toString(); + this._onInput.fire(this.value); + }); + + this.enabled = opts.enabled || true; + + if (opts.onChange) { + this._register(this.onChange(opts.onChange)); + } + + if (opts.onInput) { + this._register(this.onInput(opts.onInput)); + } + + flexContainer.append(this._el, valueBox); + this._container.appendChild(flexContainer); + } + + private updateTicksDisplay(): void { + // In order to show the tick marks we require the step since that will determine how many marks to show + if (this.showTicks && this.step) { + // Create the datalist if we haven't already + if (!this._datalist) { + this._datalist = document.createElement('datalist'); + this._datalist.id = `slider-ticks-${TICKS_DATALIST_ID++}`; + this._container.appendChild(this._datalist); + } + + this._el.setAttribute('list', this._datalist.id); + const numTicks = (this.max - this.min) / this.step; + for (let i = 0; i <= numTicks; ++i) { + const tickElement = document.createElement('option'); + tickElement.value = (this.min + (i * this.step)).toString(); + this._datalist.appendChild(tickElement); + } + } else { + this._el.removeAttribute('list'); + } + } + + public set enabled(val: boolean) { + this._el.disabled = !val; + } + + public get enabled(): boolean { + return !this._el.disabled; + } + + public set min(val: number | undefined) { + this._el.min = val?.toString() || DEFAULT_MIN; + } + + public get min(): number { + return Number(this._el.min); + } + + public set max(val: number | undefined) { + this._el.max = val?.toString() || DEFAULT_MAX; + } + + public get max(): number { + return Number(this._el.max); + } + + public set value(val: number | undefined) { + this._el.value = val?.toString() || this.min.toString(); + } + + public get value(): number { + return Number(this._el.value); + } + + public set step(val: number | undefined) { + this._el.step = val?.toString() || DEFAULT_STEP; + } + + public get step(): number { + return Number(this._el.step); + } + + public set width(val: string) { + this._el.style.width = val; + } + + public get width(): string { + return this._el.style.width; + } + + public set showTicks(val: boolean) { + this._showTicks = val; + this.updateTicksDisplay(); + } + + public get showTicks(): boolean { + return this._showTicks; + } + + public set ariaLabel(val: string | undefined) { + this._el.setAttribute('aria-label', val || ''); + } + + public get ariaLabel(): string | undefined { + return this._el.getAttribute('aria-label'); + } + + public focus(): void { + this._el.focus(); + } + + public disable(): void { + this.enabled = false; + } + + public enable(): void { + this.enabled = true; + } + + public setHeight(value: string) { + this._el.style.height = value; + } + + public setWidth(value: string) { + this._el.style.width = value; + } +} diff --git a/src/sql/platform/dashboard/browser/interfaces.ts b/src/sql/platform/dashboard/browser/interfaces.ts index d5d6b25476..286a6b5b16 100644 --- a/src/sql/platform/dashboard/browser/interfaces.ts +++ b/src/sql/platform/dashboard/browser/interfaces.ts @@ -20,7 +20,8 @@ export enum ComponentEventType { onSelectedRowChanged, onComponentCreated, onCellAction, - onEnterKeyPressed + onEnterKeyPressed, + onInput } /** @@ -145,5 +146,6 @@ export enum ModelComponentTypes { TabbedPanel, Separator, PropertiesContainer, - InfoBox + InfoBox, + Slider } diff --git a/src/sql/workbench/api/common/extHostModelView.ts b/src/sql/workbench/api/common/extHostModelView.ts index ae56e0ac79..fcc0f79517 100644 --- a/src/sql/workbench/api/common/extHostModelView.ts +++ b/src/sql/workbench/api/common/extHostModelView.ts @@ -272,6 +272,14 @@ class ModelBuilderImpl implements azdata.ModelBuilder { return builder; } + slider(): azdata.ComponentBuilder { + const id = this.getNextComponentId(); + const builder: ComponentBuilderImpl = this.getComponentBuilder(new SliderComponentWrapper(this._proxy, this._handle, id), id); + + this._componentBuilders.set(id, builder); + return builder; + } + getComponentBuilder(component: ComponentWrapper, id: string): ComponentBuilderImpl { let componentBuilder: ComponentBuilderImpl = new ComponentBuilderImpl(component); this._componentBuilders.set(id, componentBuilder); @@ -2022,6 +2030,65 @@ class InfoBoxComponentWrapper extends ComponentWrapper implements azdata.InfoBox } } +class SliderComponentWrapper extends ComponentWrapper implements azdata.SliderComponent { + constructor(proxy: MainThreadModelViewShape, handle: number, id: string) { + super(proxy, handle, ModelComponentTypes.Slider, id); + this.properties = {}; + this._emitterMap.set(ComponentEventType.onDidChange, new Emitter()); + this._emitterMap.set(ComponentEventType.onInput, new Emitter()); + } + + public get min(): number | undefined { + return this.properties['min']; + } + + public set min(v: number | undefined) { + this.setProperty('min', v); + } + + public get max(): number | undefined { + return this.properties['max']; + } + + public set max(v: number | undefined) { + this.setProperty('max', v); + } + + public get step(): number | undefined { + return this.properties['step']; + } + + public set step(v: number | undefined) { + this.setProperty('step', v); + } + + public get value(): number | undefined { + return this.properties['value']; + } + + public set value(v: number | undefined) { + this.setProperty('value', v); + } + + public get showTicks(): boolean | undefined { + return this.properties['showTicks']; + } + + public set showTicks(v: boolean | undefined) { + this.setProperty('showTicks', v); + } + + public get onChanged(): vscode.Event { + const emitter = this._emitterMap.get(ComponentEventType.onDidChange); + return emitter!.event; + } + + public get onInput(): vscode.Event { + const emitter = this._emitterMap.get(ComponentEventType.onInput); + return emitter!.event; + } +} + class GroupContainerComponentWrapper extends ComponentWrapper implements azdata.GroupContainer { constructor(proxy: MainThreadModelViewShape, handle: number, type: ModelComponentTypes, id: string) { super(proxy, handle, type, id); diff --git a/src/sql/workbench/api/common/sqlExtHostTypes.ts b/src/sql/workbench/api/common/sqlExtHostTypes.ts index b476ab023a..78ad5e9832 100644 --- a/src/sql/workbench/api/common/sqlExtHostTypes.ts +++ b/src/sql/workbench/api/common/sqlExtHostTypes.ts @@ -177,7 +177,8 @@ export enum ModelComponentTypes { TabbedPanel, Separator, PropertiesContainer, - InfoBox + InfoBox, + Slider } export enum ModelViewAction { @@ -242,7 +243,8 @@ export enum ComponentEventType { onSelectedRowChanged, onComponentCreated, onCellAction, - onEnterKeyPressed + onEnterKeyPressed, + onInput } export interface IComponentEventArgs { diff --git a/src/sql/workbench/browser/modelComponents/slider.component.ts b/src/sql/workbench/browser/modelComponents/slider.component.ts new file mode 100644 index 0000000000..2d007ae31e --- /dev/null +++ b/src/sql/workbench/browser/modelComponents/slider.component.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Component, Input, Inject, ChangeDetectorRef, forwardRef, + ViewChild, ElementRef, OnDestroy, AfterViewInit +} from '@angular/core'; + +import * as azdata from 'azdata'; + +import { ComponentBase } from 'sql/workbench/browser/modelComponents/componentBase'; +import { IComponent, IComponentDescriptor, IModelStore, ComponentEventType } from 'sql/platform/dashboard/browser/interfaces'; +import { onUnexpectedError } from 'vs/base/common/errors'; +import { ILogService } from 'vs/platform/log/common/log'; +import { Slider } from 'sql/base/browser/ui/slider/slider'; +import { convertSize } from 'sql/base/browser/dom'; + +@Component({ + selector: 'modelview-slider', + template: ` +
+ ` +}) +export default class SliderComponent extends ComponentBase implements IComponent, OnDestroy, AfterViewInit { + @Input() descriptor: IComponentDescriptor; + @Input() modelStore: IModelStore; + private _slider: Slider; + + @ViewChild('slider', { read: ElementRef }) private _sliderContainer: ElementRef; + constructor( + @Inject(forwardRef(() => ChangeDetectorRef)) changeRef: ChangeDetectorRef, + @Inject(forwardRef(() => ElementRef)) el: ElementRef, + @Inject(ILogService) logService: ILogService + ) { + super(changeRef, el, logService); + } + + ngAfterViewInit(): void { + this._slider = this._register(new Slider(this._sliderContainer.nativeElement, { + width: convertSize(this.width), + min: this.min, + max: this.max, + value: this.value, + step: this.step, + showTicks: this.showTicks + })); + this._register(this._slider.onChange(async e => { + this.value = this._slider.value; + await this.validate(); + this.fireEvent({ + eventType: ComponentEventType.onDidChange, + args: e + }); + })); + this._register(this._slider.onInput(e => { + this.fireEvent({ + eventType: ComponentEventType.onInput, + args: e + }); + })); + this.baseInit(); + } + + private get sliderElement(): Slider { + return this._slider; + } + + ngOnDestroy(): void { + this.baseDestroy(); + } + + /// IComponent implementation + + public setLayout(layout: any): void { + this.layout(); + } + + public setProperties(properties: { [key: string]: any; }): void { + super.setProperties(properties); + this.setSliderProperties(this.sliderElement); + this.validate().catch(onUnexpectedError); + } + + private setSliderProperties(slider: Slider): void { + slider.min = this.min; + slider.max = this.max; + slider.step = this.step; + slider.value = this.value; + slider.showTicks = this.showTicks; + slider.ariaLabel = this.ariaLabel; + slider.enabled = this.enabled; + slider.width = convertSize(this.width); + } + + // CSS-bound properties + + public get value(): number | undefined { + return this.getPropertyOrDefault((props) => props.value, undefined); + } + + public set value(newValue: number | undefined) { + this.setPropertyFromUI((props, value) => props.value = value, newValue); + } + + public get min(): number | undefined { + return this.getPropertyOrDefault((props) => props.min, undefined); + } + + public set min(newValue: number | undefined) { + this.setPropertyFromUI((props, value) => props.min = value, newValue); + } + + public get max(): number | undefined { + return this.getPropertyOrDefault((props) => props.max, undefined); + } + + public set max(newValue: number | undefined) { + this.setPropertyFromUI((props, value) => props.max = value, newValue); + } + + public get step(): number | undefined { + return this.getPropertyOrDefault((props) => props.step, undefined); + } + + public set step(newValue: number | undefined) { + this.setPropertyFromUI((props, value) => props.step = value, newValue); + } + + public get showTicks(): boolean | undefined { + return this.getPropertyOrDefault((props) => props.showTicks, undefined); + } + + public set showTicks(newValue: boolean | undefined) { + this.setPropertyFromUI((props, value) => props.showTicks = value, newValue); + } + + public focus(): void { + this.sliderElement.focus(); + } + + public get inputBoxCSSStyles(): azdata.CssStyles { + return this.mergeCss(super.CSSStyles, { + 'width': this.getWidth() + }); + } +} diff --git a/src/sql/workbench/contrib/modelView/browser/components.contribution.ts b/src/sql/workbench/contrib/modelView/browser/components.contribution.ts index 8d349bd970..3f50424a2b 100644 --- a/src/sql/workbench/contrib/modelView/browser/components.contribution.ts +++ b/src/sql/workbench/contrib/modelView/browser/components.contribution.ts @@ -35,6 +35,7 @@ import { ModelComponentTypes } from 'sql/platform/dashboard/browser/interfaces'; import PropertiesContainerComponent from 'sql/workbench/browser/modelComponents/propertiesContainer.component'; import ListViewComponent from 'sql/workbench/browser/modelComponents/listView.component'; import InfoBoxComponent from 'sql/workbench/browser/modelComponents/infoBox.component'; +import SliderComponent from 'sql/workbench/browser/modelComponents/slider.component'; export const DIV_CONTAINER = 'div-container'; registerComponentType(DIV_CONTAINER, ModelComponentTypes.DivContainer, DivContainer); @@ -126,3 +127,6 @@ registerComponentType(PROPERTIESCONTAINER_COMPONENT, ModelComponentTypes.Propert export const INFOBOX_COMPONENT = 'infobox-component'; registerComponentType(INFOBOX_COMPONENT, ModelComponentTypes.InfoBox, InfoBoxComponent); + +export const SLIDER_COMPONENT = 'slider-component'; +registerComponentType(SLIDER_COMPONENT, ModelComponentTypes.Slider, SliderComponent);