mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-01-25 01:25:36 -05:00
accessible radio card (#8514)
* accessible radio card group * set radio card group width * address comments * address comments 2 * fix the profile card not being focused issue
This commit is contained in:
@@ -4,23 +4,17 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ChangeDetectorRef, ElementRef } from '@angular/core';
|
||||
|
||||
import { IComponentDescriptor } from 'sql/workbench/browser/modelComponents/interfaces';
|
||||
import * as azdata from 'azdata';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { IdGenerator } from 'vs/base/common/idGenerator';
|
||||
import { createCSSRule, removeCSSRulesContainingSelector, asCSSUrl } from 'vs/base/browser/dom';
|
||||
import { ComponentBase } from 'sql/workbench/browser/modelComponents/componentBase';
|
||||
|
||||
|
||||
export type IUserFriendlyIcon = string | URI | { light: string | URI; dark: string | URI };
|
||||
import { createIconCssClass, IUserFriendlyIcon } from 'sql/workbench/browser/modelComponents/iconUtils';
|
||||
import { IComponentDescriptor } from 'sql/workbench/browser/modelComponents/interfaces';
|
||||
import { removeCSSRulesContainingSelector } from 'vs/base/browser/dom';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
|
||||
export class ItemDescriptor<T> {
|
||||
constructor(public descriptor: IComponentDescriptor, public config: T) { }
|
||||
}
|
||||
|
||||
const ids = new IdGenerator('model-view-component-icon-');
|
||||
|
||||
export abstract class ComponentWithIconBase extends ComponentBase {
|
||||
|
||||
protected _iconClass: string;
|
||||
@@ -40,42 +34,11 @@ export abstract class ComponentWithIconBase extends ComponentBase {
|
||||
protected updateIcon() {
|
||||
if (this.iconPath && this.iconPath !== this._iconPath) {
|
||||
this._iconPath = this.iconPath;
|
||||
if (!this._iconClass) {
|
||||
this._iconClass = ids.nextId();
|
||||
}
|
||||
|
||||
removeCSSRulesContainingSelector(this._iconClass);
|
||||
const icon = this.getLightIconUri(this.iconPath);
|
||||
const iconDark = this.getDarkIconUri(this.iconPath) || icon;
|
||||
createCSSRule(`.icon.${this._iconClass}`, `background-image: ${asCSSUrl(icon)}`);
|
||||
createCSSRule(`.vs-dark .icon.${this._iconClass}, .hc-black .icon.${this._iconClass}`, `background-image: ${asCSSUrl(iconDark)}`);
|
||||
this._iconClass = createIconCssClass(this.iconPath, this._iconClass);
|
||||
this._changeRef.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
private getLightIconUri(iconPath: IUserFriendlyIcon): URI {
|
||||
if (iconPath && iconPath['light']) {
|
||||
return this.getIconUri(iconPath['light']);
|
||||
} else {
|
||||
return this.getIconUri(<string | URI>iconPath);
|
||||
}
|
||||
}
|
||||
|
||||
private getDarkIconUri(iconPath: IUserFriendlyIcon): URI {
|
||||
if (iconPath && iconPath['dark']) {
|
||||
return this.getIconUri(iconPath['dark']);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getIconUri(iconPath: string | URI): URI {
|
||||
if (typeof iconPath === 'string') {
|
||||
return URI.file(iconPath);
|
||||
} else {
|
||||
return URI.revive(iconPath);
|
||||
}
|
||||
}
|
||||
|
||||
public getIconWidth(): string {
|
||||
return this.convertSize(this.iconWidth, '40px');
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { registerComponentType } from 'sql/platform/dashboard/browser/modelCompo
|
||||
import { ModelComponentTypes } from 'sql/workbench/api/common/sqlExtHostTypes';
|
||||
import HyperlinkComponent from 'sql/workbench/browser/modelComponents/hyperlink.component';
|
||||
import SplitViewContainer from 'sql/workbench/browser/modelComponents/splitviewContainer.component';
|
||||
import RadioCardGroup from 'sql/workbench/browser/modelComponents/radioCardGroup.component';
|
||||
|
||||
export const DIV_CONTAINER = 'div-container';
|
||||
registerComponentType(DIV_CONTAINER, ModelComponentTypes.DivContainer, DivContainer);
|
||||
@@ -105,3 +106,7 @@ registerComponentType(DOM_COMPONENT, ModelComponentTypes.Dom, DomComponent);
|
||||
|
||||
export const HYPERLINK_COMPONENT = 'hyperlink-component';
|
||||
registerComponentType(HYPERLINK_COMPONENT, ModelComponentTypes.Hyperlink, HyperlinkComponent);
|
||||
|
||||
export const RADIOCARDGROUP_COMPONENT = 'radiocardgroup-component';
|
||||
registerComponentType(RADIOCARDGROUP_COMPONENT, ModelComponentTypes.RadioCardGroup, RadioCardGroup);
|
||||
|
||||
|
||||
54
src/sql/workbench/browser/modelComponents/iconUtils.ts
Normal file
54
src/sql/workbench/browser/modelComponents/iconUtils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { asCSSUrl, createCSSRule, removeCSSRulesContainingSelector } from 'vs/base/browser/dom';
|
||||
import { IdGenerator } from 'vs/base/common/idGenerator';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
|
||||
const ids = new IdGenerator('model-view-component-icon-');
|
||||
|
||||
export type IUserFriendlyIcon = string | URI | { light: string | URI; dark: string | URI };
|
||||
|
||||
/**
|
||||
* Create a CSS class for the specified icon, if a class with the name already exists, it will be deleted first.
|
||||
* @param iconPath icon specification
|
||||
* @param className optional, the class name you want to reuse.
|
||||
* @returns the CSS class name
|
||||
*/
|
||||
export function createIconCssClass(iconPath: IUserFriendlyIcon, className?: string): string {
|
||||
let iconClass = className;
|
||||
if (!iconClass) {
|
||||
iconClass = ids.nextId();
|
||||
}
|
||||
removeCSSRulesContainingSelector(iconClass);
|
||||
const icon = getLightIconUri(iconPath);
|
||||
const iconDark = getDarkIconUri(iconPath) || icon;
|
||||
createCSSRule(`.icon.${iconClass}`, `background-image: ${asCSSUrl(icon)}`);
|
||||
createCSSRule(`.vs-dark .icon.${iconClass}, .hc-black .icon.${iconClass}`, `background-image: ${asCSSUrl(iconDark)}`);
|
||||
return iconClass;
|
||||
}
|
||||
|
||||
function getLightIconUri(iconPath: IUserFriendlyIcon): URI {
|
||||
if (iconPath && iconPath['light']) {
|
||||
return getIconUri(iconPath['light']);
|
||||
} else {
|
||||
return getIconUri(<string | URI>iconPath);
|
||||
}
|
||||
}
|
||||
|
||||
function getDarkIconUri(iconPath: IUserFriendlyIcon): URI {
|
||||
if (iconPath && iconPath['dark']) {
|
||||
return getIconUri(iconPath['dark']);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getIconUri(iconPath: string | URI): URI {
|
||||
if (typeof iconPath === 'string') {
|
||||
return URI.file(iconPath);
|
||||
} else {
|
||||
return URI.revive(iconPath);
|
||||
}
|
||||
}
|
||||
@@ -13,16 +13,7 @@
|
||||
border-style: solid;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.model-card-list-item.selected, .model-card.selected {
|
||||
border-color: rgb(0, 120, 215);
|
||||
box-shadow: rgba(0, 120, 215, 0.75) 0px 0px 6px;
|
||||
}
|
||||
|
||||
.model-card-list-item.unselected, .model-card.unselected {
|
||||
border-color: rgb(214, 214, 214);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.model-card .card-content {
|
||||
@@ -122,7 +113,7 @@
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
border-width: 1px;
|
||||
border-color: rgb(0, 120, 215);
|
||||
border-color: rgb(214, 214, 214);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
@@ -207,3 +198,22 @@
|
||||
.model-card-list-item-description-value {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.card-group {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
}
|
||||
|
||||
.model-card-description-table {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.model-card-description-label-column {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.model-card-description-value-column {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<div role="radiogroup" *ngIf="cards" class="card-group" style="flex-wrap:wrap" [style.height]="height"
|
||||
[style.width]="width" [attr.aria-label]="ariaLabel" (keydown)="onKeyDown($event)">
|
||||
<div #cardDiv role="radio" *ngFor="let card of cards" class="model-card" (click)="selectCard(card)"
|
||||
[attr.aria-checked]="isCardSelected(card)" [tabIndex]="getTabIndex(card)" [style.width]="cardWidth"
|
||||
[style.height]="cardHeight" (focus)="onCardFocus(card)" (blur)="onCardBlur(card)" style="flex:0 0 auto;">
|
||||
<span class="selection-indicator-container">
|
||||
<div *ngIf="isCardSelected(card)" class="selection-indicator"></div>
|
||||
</span>
|
||||
<div class="card-vertical-button">
|
||||
<div *ngIf="card.icon" class="iconContainer">
|
||||
<div [class]="getIconClass(card)" [style.width]="iconWidth" [style.height]="iconHeight"></div>
|
||||
</div>
|
||||
<h4 class="card-label">{{card.label}}</h4>
|
||||
<div *ngIf="card.descriptions && card.descriptions.length > 0" class="model-card-description-container">
|
||||
<ng-container *ngFor="let desc of card.descriptions">
|
||||
<table class="model-card-description-table" [attr.aria-label]="desc.ariaLabel">
|
||||
<tr>
|
||||
<th class="model-card-description-label-column">{{desc.labelHeader}}</th>
|
||||
<th class="model-card-description-value-column" *ngIf="desc.valueHeader">
|
||||
{{desc.valueHeader}}</th>
|
||||
</tr>
|
||||
<tr *ngFor="let content of desc.contents">
|
||||
<td class="model-card-description-label-column">{{content.label}}</td>
|
||||
<td class="model-card-description-value-column" *ngIf="content.value">{{content.value}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,176 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { ChangeDetectorRef, Component, ElementRef, forwardRef, Inject, Input, OnDestroy, QueryList, ViewChildren } from '@angular/core';
|
||||
import * as azdata from 'azdata';
|
||||
import { ComponentBase } from 'sql/workbench/browser/modelComponents/componentBase';
|
||||
import { createIconCssClass } from 'sql/workbench/browser/modelComponents/iconUtils';
|
||||
import { ComponentEventType, IComponent, IComponentDescriptor, IModelStore } from 'sql/workbench/browser/modelComponents/interfaces';
|
||||
import * as DOM from 'vs/base/browser/dom';
|
||||
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
|
||||
import { KeyCode } from 'vs/base/common/keyCodes';
|
||||
import 'vs/css!./media/card';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
|
||||
@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<ElementRef>;
|
||||
|
||||
private selectedCard: azdata.RadioCard;
|
||||
private focusedCard: azdata.RadioCard;
|
||||
private iconClasses: { [key: string]: string } = {};
|
||||
|
||||
constructor(
|
||||
@Inject(forwardRef(() => ChangeDetectorRef)) changeRef: ChangeDetectorRef,
|
||||
@Inject(forwardRef(() => ElementRef)) el: ElementRef,
|
||||
@Inject(ILogService) private _logService: ILogService
|
||||
) {
|
||||
super(changeRef, el);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.baseInit();
|
||||
}
|
||||
|
||||
setLayout(layout: any): void {
|
||||
this.layout();
|
||||
}
|
||||
|
||||
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.focusedCard && !this.selectedCard) {
|
||||
this.selectCard(this.focusedCard);
|
||||
}
|
||||
DOM.EventHelper.stop(e, true);
|
||||
}
|
||||
else if (e.keyCode === KeyCode.LeftArrow || e.keyCode === KeyCode.UpArrow) {
|
||||
if (this.focusedCard) {
|
||||
this.selectCard(this.findPreviousCard(this.focusedCard));
|
||||
}
|
||||
DOM.EventHelper.stop(e, true);
|
||||
} else if (e.keyCode === KeyCode.RightArrow || e.keyCode === KeyCode.DownArrow) {
|
||||
if (this.focusedCard) {
|
||||
this.selectCard(this.findNextCard(this.focusedCard));
|
||||
}
|
||||
DOM.EventHelper.stop(e, true);
|
||||
}
|
||||
}
|
||||
|
||||
private findPreviousCard(currentCard: azdata.RadioCard): azdata.RadioCard {
|
||||
const currentIndex = this.cards.indexOf(currentCard);
|
||||
const previousCardIndex = currentIndex === 0 ? this.cards.length - 1 : currentIndex - 1;
|
||||
return this.cards[previousCardIndex];
|
||||
}
|
||||
|
||||
private findNextCard(currentCard: azdata.RadioCard): azdata.RadioCard {
|
||||
const currentIndex = this.cards.indexOf(currentCard);
|
||||
const nextCardIndex = currentIndex === this.cards.length - 1 ? 0 : currentIndex + 1;
|
||||
return this.cards[nextCardIndex];
|
||||
}
|
||||
|
||||
public get cards(): azdata.RadioCard[] {
|
||||
return this.getPropertyOrDefault<azdata.RadioCardGroupComponentProperties, azdata.RadioCard[]>((props) => props.cards, []);
|
||||
}
|
||||
|
||||
public get cardWidth(): string | undefined {
|
||||
return this.getPropertyOrDefault<azdata.RadioCardGroupComponentProperties, string | undefined>((props) => props.cardWidth, undefined);
|
||||
}
|
||||
|
||||
public get cardHeight(): string | undefined {
|
||||
return this.getPropertyOrDefault<azdata.RadioCardGroupComponentProperties, string | undefined>((props) => props.cardHeight, undefined);
|
||||
}
|
||||
|
||||
public get iconWidth(): string | undefined {
|
||||
return this.getPropertyOrDefault<azdata.RadioCardGroupComponentProperties, string | undefined>((props) => props.iconWidth, undefined);
|
||||
}
|
||||
|
||||
public get iconHeight(): string | undefined {
|
||||
return this.getPropertyOrDefault<azdata.RadioCardGroupComponentProperties, string | undefined>((props) => props.iconHeight, undefined);
|
||||
}
|
||||
|
||||
public get selectedCardId(): string | undefined {
|
||||
return this.getPropertyOrDefault<azdata.RadioCardGroupComponentProperties, string | undefined>((props) => props.selectedCardId, undefined);
|
||||
}
|
||||
|
||||
public getIconClass(card: azdata.RadioCard): string {
|
||||
if (!this.iconClasses[card.id]) {
|
||||
this.iconClasses[card.id] = `cardIcon icon ${createIconCssClass(card.icon)}`;
|
||||
}
|
||||
return this.iconClasses[card.id];
|
||||
}
|
||||
|
||||
public setProperties(properties: { [key: string]: any }) {
|
||||
super.setProperties(properties);
|
||||
// This is the entry point for the extension to set the selectedCardId
|
||||
if (this.selectedCardId) {
|
||||
const filteredCards = this.cards.filter(c => { return c.id === this.selectedCardId; });
|
||||
if (filteredCards.length === 1) {
|
||||
this.selectCard(filteredCards[0]);
|
||||
} else {
|
||||
this._logService.error(`There should be one and only one matching card for the giving selectedCardId, actual number: ${filteredCards.length}, selectedCardId: ${this.selectedCardId} $`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public selectCard(card: azdata.RadioCard): void {
|
||||
if (!this.enabled || this.selectedCard === card || this.cards.indexOf(card) === -1) {
|
||||
return;
|
||||
}
|
||||
this.selectedCard = card;
|
||||
this._changeRef.detectChanges();
|
||||
const cardElement = this.getCardElement(this.selectedCard);
|
||||
cardElement.nativeElement.focus();
|
||||
this.setPropertyFromUI<azdata.RadioCardGroupComponentProperties, string | undefined>((props, value) => props.selectedCardId = value, card.id);
|
||||
this.fireEvent({
|
||||
eventType: ComponentEventType.onDidChange,
|
||||
args: this.selectedCard.id
|
||||
});
|
||||
}
|
||||
|
||||
public getCardElement(card: azdata.RadioCard): ElementRef {
|
||||
return this.cardElements.toArray()[this.cards.indexOf(card)];
|
||||
}
|
||||
|
||||
public getTabIndex(card: azdata.RadioCard): number {
|
||||
if (!this.enabled) {
|
||||
return -1;
|
||||
}
|
||||
else if (!this.selectedCard) {
|
||||
return this.cards.indexOf(card) === 0 ? 0 : -1;
|
||||
} else {
|
||||
return card === this.selectedCard ? 0 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
public isCardSelected(card: azdata.RadioCard): boolean {
|
||||
return card === this.selectedCard;
|
||||
}
|
||||
|
||||
public onCardFocus(card: azdata.RadioCard): void {
|
||||
this.focusedCard = card;
|
||||
this._changeRef.detectChanges();
|
||||
}
|
||||
|
||||
public onCardBlur(card: azdata.RadioCard): void {
|
||||
this.focusedCard = undefined;
|
||||
this._changeRef.detectChanges();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user