accessibility improvements (#193571)

This commit is contained in:
Sandeep Somavarapu
2023-09-20 14:40:51 +02:00
committed by GitHub
parent 1da15ea905
commit 3503252879
3 changed files with 105 additions and 42 deletions

View File

@@ -9,6 +9,7 @@
.icon-select-box .icon-select-icons-container {
height: 100%;
outline: 0 !important;
}
.icon-select-box .icon-select-icons-container > .icon-container {
@@ -31,3 +32,7 @@
padding: 10px;
opacity: .8;
}
.icon-select-box .icon-select-id-container .icon-select-id-label .highlight {
color: var(--vscode-list-highlightForeground);
}

View File

@@ -14,27 +14,37 @@ import { ThemeIcon } from 'vs/base/common/themables';
import { localize } from 'vs/nls';
import { IMatch } from 'vs/base/common/filters';
import { ScrollbarVisibility } from 'vs/base/common/scrollable';
import { HighlightedLabel } from 'vs/base/browser/ui/highlightedlabel/highlightedLabel';
export interface IIconSelectBoxOptions {
readonly icons: ThemeIcon[];
readonly inputBoxStyles: IInputBoxStyles;
}
interface IRenderedIconItem {
readonly icon: ThemeIcon;
readonly element: HTMLElement;
readonly highlightMatches?: IMatch[];
}
export class IconSelectBox extends Disposable {
private static InstanceCount = 0;
readonly domId = `icon_select_box_id_${++IconSelectBox.InstanceCount}`;
readonly domNode: HTMLElement;
private _onDidSelect = this._register(new Emitter<ThemeIcon>());
readonly onDidSelect = this._onDidSelect.event;
private renderedIcons: [ThemeIcon, HTMLElement][] = [];
private renderedIcons: IRenderedIconItem[] = [];
private focusedItemIndex: number = 0;
private numberOfElementsPerRow: number = 1;
private inputBox: InputBox | undefined;
protected inputBox: InputBox | undefined;
private scrollableElement: DomScrollableElement | undefined;
private iconIdElement: HTMLElement | undefined;
private iconIdElement: HighlightedLabel | undefined;
private readonly iconContainerWidth = 36;
private readonly iconContainerHeight = 32;
@@ -59,47 +69,61 @@ export class IconSelectBox extends Disposable {
inputBoxStyles: this.options.inputBoxStyles,
}));
const iconsContainer = dom.$('.icon-select-icons-container');
const iconsContainer = dom.$('.icon-select-icons-container', { id: `${this.domId}_icons` });
iconsContainer.style.paddingRight = '10px';
iconsContainer.role = 'listbox';
iconsContainer.tabIndex = 0;
this.scrollableElement = disposables.add(new DomScrollableElement(iconsContainer, {
useShadows: false,
horizontal: ScrollbarVisibility.Hidden,
}));
dom.append(iconSelectBoxContainer, this.scrollableElement.getDomNode());
this.iconIdElement = dom.append(dom.append(iconSelectBoxContainer, dom.$('.icon-select-id-container')), dom.$('.icon-select-id-label'));
this.iconIdElement = new HighlightedLabel(dom.append(dom.append(iconSelectBoxContainer, dom.$('.icon-select-id-container')), dom.$('.icon-select-id-label')));
const iconsDisposables = disposables.add(new MutableDisposable());
iconsDisposables.value = this.renderIcons(this.options.icons, iconsContainer);
iconsDisposables.value = this.renderIcons(this.options.icons, [], iconsContainer);
this.scrollableElement.scanDomNode();
disposables.add(this.inputBox.onDidChange(value => {
const icons = this.options.icons.filter(icon => {
return this.matchesContiguous(value, icon.id);
});
iconsDisposables.value = this.renderIcons(icons, iconsContainer);
const icons = [], matches = [];
for (const icon of this.options.icons) {
const match = this.matchesContiguous(value, icon.id);
if (match) {
icons.push(icon);
matches.push(match);
}
}
iconsDisposables.value = this.renderIcons(icons, matches, iconsContainer);
this.scrollableElement?.scanDomNode();
}));
this.inputBox.inputElement.role = 'combobox';
this.inputBox.inputElement.ariaHasPopup = 'menu';
this.inputBox.inputElement.ariaAutoComplete = 'list';
this.inputBox.inputElement.ariaExpanded = 'true';
this.inputBox.inputElement.setAttribute('aria-controls', iconsContainer.id);
return disposables;
}
private renderIcons(icons: ThemeIcon[], container: HTMLElement): IDisposable {
private renderIcons(icons: ThemeIcon[], matches: IMatch[][], container: HTMLElement): IDisposable {
const disposables = new DisposableStore();
dom.clearNode(container);
const focusedIcon = this.renderedIcons[this.focusedItemIndex]?.[0];
const focusedIcon = this.renderedIcons[this.focusedItemIndex]?.icon;
let focusedIconIndex = 0;
const renderedIcons: [ThemeIcon, HTMLElement][] = [];
const renderedIcons: IRenderedIconItem[] = [];
if (icons.length) {
for (let index = 0; index < icons.length; index++) {
const icon = icons[index];
const iconContainer = dom.append(container, dom.$('.icon-container'));
const iconContainer = dom.append(container, dom.$('.icon-container', { id: `${this.domId}_icons_${index}` }));
iconContainer.style.width = `${this.iconContainerWidth}px`;
iconContainer.style.height = `${this.iconContainerHeight}px`;
iconContainer.tabIndex = -1;
iconContainer.role = 'button';
iconContainer.title = icon.id;
iconContainer.role = 'button';
iconContainer.setAttribute('aria-setsize', `${icons.length}`);
iconContainer.setAttribute('aria-posinset', `${index + 1}`);
dom.append(iconContainer, dom.$(ThemeIcon.asCSSSelector(icon)));
renderedIcons.push([icon, iconContainer]);
renderedIcons.push({ icon, element: iconContainer, highlightMatches: matches[index] });
disposables.add(dom.addDisposableListener(iconContainer, dom.EventType.CLICK, (e: MouseEvent) => {
e.stopPropagation();
@@ -129,17 +153,30 @@ export class IconSelectBox extends Disposable {
private focusIcon(index: number): void {
const existing = this.renderedIcons[this.focusedItemIndex];
if (existing) {
existing[1].classList.remove('focused');
existing.element.classList.remove('focused');
}
this.focusedItemIndex = index;
const icon = this.renderedIcons[index]?.[1];
if (icon) {
icon.classList.add('focused');
const renderedItem = this.renderedIcons[index];
if (renderedItem) {
renderedItem.element.classList.add('focused');
}
if (this.inputBox) {
if (renderedItem) {
this.inputBox.inputElement.setAttribute('aria-activedescendant', renderedItem.element.id);
} else {
this.inputBox.inputElement.removeAttribute('aria-activedescendant');
}
}
if (this.iconIdElement) {
this.iconIdElement.textContent = this.renderedIcons[index]?.[0].id;
if (renderedItem) {
this.iconIdElement.set(renderedItem.icon.id, renderedItem.highlightMatches);
} else {
this.iconIdElement.set('');
}
}
this.reveal(index);
@@ -152,16 +189,16 @@ export class IconSelectBox extends Disposable {
if (index < 0 || index >= this.renderedIcons.length) {
return;
}
const icon = this.renderedIcons[index][1];
if (!icon) {
const element = this.renderedIcons[index].element;
if (!element) {
return;
}
const { height } = this.scrollableElement.getScrollDimensions();
const { scrollTop } = this.scrollableElement.getScrollPosition();
if (icon.offsetTop + this.iconContainerHeight > scrollTop + height) {
this.scrollableElement.setScrollPosition({ scrollTop: icon.offsetTop + this.iconContainerHeight - height });
} else if (icon.offsetTop < scrollTop) {
this.scrollableElement.setScrollPosition({ scrollTop: icon.offsetTop });
if (element.offsetTop + this.iconContainerHeight > scrollTop + height) {
this.scrollableElement.setScrollPosition({ scrollTop: element.offsetTop + this.iconContainerHeight - height });
} else if (element.offsetTop < scrollTop) {
this.scrollableElement.setScrollPosition({ scrollTop: element.offsetTop });
}
}
@@ -185,8 +222,8 @@ export class IconSelectBox extends Disposable {
const extraSpace = iconsContainerWidth % this.iconContainerWidth;
const margin = Math.floor(extraSpace / this.numberOfElementsPerRow);
for (const [, icon] of this.renderedIcons) {
icon.style.marginRight = `${margin}px`;
for (const { element } of this.renderedIcons) {
element.style.marginRight = `${margin}px`;
}
if (this.scrollableElement) {
@@ -204,7 +241,7 @@ export class IconSelectBox extends Disposable {
throw new Error(`Invalid index ${index}`);
}
this.focusIcon(index);
this._onDidSelect.fire(this.renderedIcons[index][0]);
this._onDidSelect.fire(this.renderedIcons[index].icon);
}
focus(): void {
@@ -224,6 +261,7 @@ export class IconSelectBox extends Disposable {
let nextRowIndex = this.focusedItemIndex + this.numberOfElementsPerRow;
if (nextRowIndex >= this.renderedIcons.length) {
nextRowIndex = (nextRowIndex + 1) % this.numberOfElementsPerRow;
nextRowIndex = nextRowIndex >= this.renderedIcons.length ? 0 : nextRowIndex;
}
this.focusIcon(nextRowIndex);
}
@@ -233,13 +271,17 @@ export class IconSelectBox extends Disposable {
if (previousRowIndex < 0) {
const numberOfRows = Math.floor(this.renderedIcons.length / this.numberOfElementsPerRow);
previousRowIndex = this.focusedItemIndex + (this.numberOfElementsPerRow * numberOfRows) - 1;
previousRowIndex = previousRowIndex >= this.renderedIcons.length ? previousRowIndex - this.numberOfElementsPerRow : previousRowIndex;
previousRowIndex = previousRowIndex < 0
? this.renderedIcons.length - 1
: previousRowIndex >= this.renderedIcons.length
? previousRowIndex - this.numberOfElementsPerRow
: previousRowIndex;
}
this.focusIcon(previousRowIndex);
}
getFocusedIcon(): ThemeIcon {
return this.renderedIcons[this.focusedItemIndex][0];
return this.renderedIcons[this.focusedItemIndex].icon;
}
}

View File

@@ -5,10 +5,13 @@
import { IIconSelectBoxOptions, IconSelectBox } from 'vs/base/browser/ui/icons/iconSelectBox';
import { KeyCode } from 'vs/base/common/keyCodes';
import { IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import * as dom from 'vs/base/browser/dom';
import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
export const WorkbenchIconSelectBoxFocusContextKey = new RawContextKey<boolean>('iconSelectBoxFocus', true);
export const WorkbenchIconSelectBoxInputFocusContextKey = new RawContextKey<boolean>('iconSelectBoxInputFocus', true);
export const WorkbenchIconSelectBoxInputEmptyContextKey = new RawContextKey<boolean>('iconSelectBoxInputEmpty', true);
export class WorkbenchIconSelectBox extends IconSelectBox {
@@ -17,12 +20,25 @@ export class WorkbenchIconSelectBox extends IconSelectBox {
return WorkbenchIconSelectBox.focusedWidget;
}
private readonly contextKeyService: IContextKeyService;
private readonly inputFocusContextKey: IContextKey<boolean>;
private readonly inputEmptyContextKey: IContextKey<boolean>;
constructor(
options: IIconSelectBoxOptions,
@IContextKeyService contextKeyService: IContextKeyService
) {
super(options);
WorkbenchIconSelectBoxFocusContextKey.bindTo(this._register(contextKeyService.createScoped(this.domNode)));
this.contextKeyService = this._register(contextKeyService.createScoped(this.domNode));
WorkbenchIconSelectBoxFocusContextKey.bindTo(this.contextKeyService);
this.inputFocusContextKey = WorkbenchIconSelectBoxInputFocusContextKey.bindTo(this.contextKeyService);
this.inputEmptyContextKey = WorkbenchIconSelectBoxInputEmptyContextKey.bindTo(this.contextKeyService);
if (this.inputBox) {
const focusTracker = this._register(dom.trackFocus(this.inputBox.inputElement));
this._register(focusTracker.onDidFocus(() => this.inputFocusContextKey.set(true)));
this._register(focusTracker.onDidBlur(() => this.inputFocusContextKey.set(false)));
this._register(this.inputBox.onDidChange(() => this.inputEmptyContextKey.set(this.inputBox?.value.length === 0)));
}
}
override focus(): void {
@@ -37,7 +53,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({
weight: KeybindingWeight.WorkbenchContrib,
when: WorkbenchIconSelectBoxFocusContextKey,
primary: KeyCode.UpArrow,
handler: (accessor, arg2) => {
handler: () => {
const selectBox = WorkbenchIconSelectBox.getFocusedWidget();
if (selectBox) {
selectBox.focusPreviousRow();
@@ -50,7 +66,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({
weight: KeybindingWeight.WorkbenchContrib,
when: WorkbenchIconSelectBoxFocusContextKey,
primary: KeyCode.DownArrow,
handler: (accessor, arg2) => {
handler: () => {
const selectBox = WorkbenchIconSelectBox.getFocusedWidget();
if (selectBox) {
selectBox.focusNextRow();
@@ -61,9 +77,9 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({
KeybindingsRegistry.registerCommandAndKeybindingRule({
id: 'iconSelectBox.focusNext',
weight: KeybindingWeight.WorkbenchContrib,
when: WorkbenchIconSelectBoxFocusContextKey,
when: ContextKeyExpr.and(WorkbenchIconSelectBoxFocusContextKey, ContextKeyExpr.or(WorkbenchIconSelectBoxInputEmptyContextKey, WorkbenchIconSelectBoxInputFocusContextKey.toNegated())),
primary: KeyCode.RightArrow,
handler: (accessor, arg2) => {
handler: () => {
const selectBox = WorkbenchIconSelectBox.getFocusedWidget();
if (selectBox) {
selectBox.focusNext();
@@ -74,9 +90,9 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({
KeybindingsRegistry.registerCommandAndKeybindingRule({
id: 'iconSelectBox.focusPrevious',
weight: KeybindingWeight.WorkbenchContrib,
when: WorkbenchIconSelectBoxFocusContextKey,
when: ContextKeyExpr.and(WorkbenchIconSelectBoxFocusContextKey, ContextKeyExpr.or(WorkbenchIconSelectBoxInputEmptyContextKey, WorkbenchIconSelectBoxInputFocusContextKey.toNegated())),
primary: KeyCode.LeftArrow,
handler: (accessor, arg2) => {
handler: () => {
const selectBox = WorkbenchIconSelectBox.getFocusedWidget();
if (selectBox) {
selectBox.focusPrevious();
@@ -89,7 +105,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({
weight: KeybindingWeight.WorkbenchContrib,
when: WorkbenchIconSelectBoxFocusContextKey,
primary: KeyCode.Enter,
handler: (accessor, arg2) => {
handler: () => {
const selectBox = WorkbenchIconSelectBox.getFocusedWidget();
if (selectBox) {
selectBox.setSelection(selectBox.getFocus()[0]);