/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import 'vs/css!./media/card'; import 'vs/css!./media/verticalCard'; import { ChangeDetectorRef, Component, ElementRef, forwardRef, Inject, Input, OnDestroy, QueryList, ViewChildren } from '@angular/core'; import * as azdata from 'azdata'; import { ComponentEventType, IComponent, IComponentDescriptor, IModelStore } from 'sql/platform/dashboard/browser/interfaces'; import { ComponentBase } from 'sql/workbench/browser/modelComponents/componentBase'; import { createIconCssClass } from 'sql/workbench/browser/modelComponents/iconUtils'; import * as DOM from 'vs/base/browser/dom'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { KeyCode } from 'vs/base/common/keyCodes'; import { deepClone } from 'vs/base/common/objects'; import { ILogService } from 'vs/platform/log/common/log'; import { focusBorder, textLinkForeground } from 'vs/platform/theme/common/colorRegistry'; import { IColorTheme, ICssStyleCollector, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; @Component({ templateUrl: decodeURI(require.toUrl('./radioCardGroup.component.html')) }) export default class RadioCardGroup extends ComponentBase implements IComponent, OnDestroy { @Input() descriptor: IComponentDescriptor; @Input() modelStore: IModelStore; @ViewChildren('cardDiv') cardElements: QueryList; private focusedCardId: string | undefined; private iconClasses: { [key: string]: string } = {}; constructor( @Inject(forwardRef(() => ChangeDetectorRef)) changeRef: ChangeDetectorRef, @Inject(forwardRef(() => ElementRef)) el: ElementRef, @Inject(ILogService) logService: ILogService ) { super(changeRef, el, logService); } ngAfterViewInit(): void { this.baseInit(); } setLayout(layout: any): void { this.layout(); } override ngOnDestroy(): void { Object.keys(this.iconClasses).forEach((key) => { DOM.removeCSSRulesContainingSelector(this.iconClasses[key]); }); this.baseDestroy(); } onKeyDown(event: KeyboardEvent): void { if (!this.enabled || this.cards.length === 0) { return; } let e = new StandardKeyboardEvent(event); if (e.keyCode === KeyCode.Enter || e.keyCode === KeyCode.Space) { if (this.focusedCardId && !this.selectedCardId) { this.selectCard(this.focusedCardId); } DOM.EventHelper.stop(e, true); } else if (e.keyCode === KeyCode.LeftArrow || e.keyCode === KeyCode.UpArrow) { if (this.focusedCardId) { this.selectCard(this.findPreviousCard(this.focusedCardId)); } DOM.EventHelper.stop(e, true); } else if (e.keyCode === KeyCode.RightArrow || e.keyCode === KeyCode.DownArrow) { if (this.focusedCardId) { this.selectCard(this.findNextCard(this.focusedCardId)); } DOM.EventHelper.stop(e, true); } } private getCardById(cardId: string): azdata.RadioCard { const filteredCards = this.cards.filter(c => { return c.id === cardId; }); if (filteredCards.length === 1) { return filteredCards[0]; } else { throw new Error(`There should be one and only one matching card for the giving card id, actual number: ${filteredCards.length}, card id: ${cardId}.`); } } private findPreviousCard(cardId: string): string { const currentIndex = this.cards.indexOf(this.getCardById(cardId)); const previousCardIndex = currentIndex === 0 ? this.cards.length - 1 : currentIndex - 1; return this.cards[previousCardIndex].id; } private findNextCard(cardId: string): string { const currentIndex = this.cards.indexOf(this.getCardById(cardId)); const nextCardIndex = currentIndex === this.cards.length - 1 ? 0 : currentIndex + 1; return this.cards[nextCardIndex].id; } public get cards(): azdata.RadioCard[] { return this.getProperties().cards ?? []; } public get cardWidth(): string | undefined { return this.getProperties().cardWidth ?? undefined; } public get cardHeight(): string | undefined { return this.getProperties().cardHeight ?? undefined; } public get iconWidth(): string | undefined { return this.getProperties().iconWidth ?? undefined; } public get iconHeight(): string | undefined { return this.getProperties().iconHeight ?? undefined; } public get textHeight(): string | undefined { return this.calculateTextContainerHeight(); } public calculateTextContainerHeight(): string | undefined { if (this.cardHeight.endsWith('px') && this.iconHeight.endsWith('px')) { const padding = 30; // icon-container padding + text-container padding let height = Number.parseInt(this.cardHeight.substr(0, this.cardHeight.length - 2)) - Number.parseInt(this.iconHeight.substr(0, this.cardHeight.length - 2)); height = height - padding; return height.toString() + 'px'; } else { return undefined; } } public get selectedCardId(): string | undefined { return this.getProperties().selectedCardId ?? undefined; } public get iconPosition(): string { return this.getProperties().iconPosition ?? 'top'; } public isIconPositionTop(): boolean { return this.iconPosition === 'top'; } public isIconPositionLeft(): boolean { return this.iconPosition === 'left'; } public get orientation(): string { const x = this.getProperties().orientation ?? 'horizontal'; return x; } public getIconClass(cardId: string): string { if (!this.iconClasses[cardId]) { this.iconClasses[cardId] = `cardIcon icon ${createIconCssClass(this.getCardById(cardId).icon)}`; } return this.iconClasses[cardId]; } public override setProperties(properties: { [key: string]: any }) { super.setProperties(properties); // This is the entry point for the extension to set the selectedCardId if (this.selectedCardId) { this.selectCard(this.selectedCardId); } } public selectCard(cardId: string): void { if (!this.enabled || this.cards.length === 0) { return; } const cardElement = this.getCardElement(cardId); cardElement.nativeElement.focus(); this.setPropertyFromUI((props, value) => props.selectedCardId = value, cardId); this._changeRef.detectChanges(); this.fireEvent({ eventType: ComponentEventType.onDidChange, args: { cardId, card: deepClone(this.getCardById(cardId)) } }); } public onLinkClick(event: Event, cardId: string, textContents: azdata.RadioCardDescription): void { event.stopPropagation(); this.fireEvent({ eventType: ComponentEventType.onDidClick, args: { cardId, description: deepClone(textContents), card: deepClone(this.getCardById(cardId)) } }); } public getCardElement(cardId: string): ElementRef { const card = this.getCardById(cardId); return this.cardElements.toArray()[this.cards.indexOf(card)]; } public getTabIndex(cardId: string): number { if (!this.enabled) { return -1; } else if (!this.selectedCardId) { return this.cards.indexOf(this.getCardById(cardId)) === 0 ? 0 : -1; } else { return cardId === this.selectedCardId ? 0 : -1; } } public isCardSelected(cardId: string): boolean { return cardId === this.selectedCardId; } public onCardFocus(cardId: string): void { this.focusedCardId = cardId; } public onCardBlur(cardId: string): void { this.focusedCardId = undefined; } public override get CSSStyles(): azdata.CssStyles { return this.mergeCss(super.CSSStyles, { 'width': this.getWidth(), 'height': this.getHeight() }); } } registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { const linkForeground = theme.getColor(textLinkForeground); const focusOutline = theme.getColor(focusBorder); if (focusOutline && linkForeground) { collector.addRule(` .card-group .link-value { color: ${linkForeground}; } .card-group .link-value:focus { outline-color: ${focusOutline}; }`); } });