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 { .icon-select-box .icon-select-icons-container {
height: 100%; height: 100%;
outline: 0 !important;
} }
.icon-select-box .icon-select-icons-container > .icon-container { .icon-select-box .icon-select-icons-container > .icon-container {
@@ -31,3 +32,7 @@
padding: 10px; padding: 10px;
opacity: .8; 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 { localize } from 'vs/nls';
import { IMatch } from 'vs/base/common/filters'; import { IMatch } from 'vs/base/common/filters';
import { ScrollbarVisibility } from 'vs/base/common/scrollable'; import { ScrollbarVisibility } from 'vs/base/common/scrollable';
import { HighlightedLabel } from 'vs/base/browser/ui/highlightedlabel/highlightedLabel';
export interface IIconSelectBoxOptions { export interface IIconSelectBoxOptions {
readonly icons: ThemeIcon[]; readonly icons: ThemeIcon[];
readonly inputBoxStyles: IInputBoxStyles; readonly inputBoxStyles: IInputBoxStyles;
} }
interface IRenderedIconItem {
readonly icon: ThemeIcon;
readonly element: HTMLElement;
readonly highlightMatches?: IMatch[];
}
export class IconSelectBox extends Disposable { export class IconSelectBox extends Disposable {
private static InstanceCount = 0;
readonly domId = `icon_select_box_id_${++IconSelectBox.InstanceCount}`;
readonly domNode: HTMLElement; readonly domNode: HTMLElement;
private _onDidSelect = this._register(new Emitter<ThemeIcon>()); private _onDidSelect = this._register(new Emitter<ThemeIcon>());
readonly onDidSelect = this._onDidSelect.event; readonly onDidSelect = this._onDidSelect.event;
private renderedIcons: [ThemeIcon, HTMLElement][] = []; private renderedIcons: IRenderedIconItem[] = [];
private focusedItemIndex: number = 0; private focusedItemIndex: number = 0;
private numberOfElementsPerRow: number = 1; private numberOfElementsPerRow: number = 1;
private inputBox: InputBox | undefined; protected inputBox: InputBox | undefined;
private scrollableElement: DomScrollableElement | undefined; private scrollableElement: DomScrollableElement | undefined;
private iconIdElement: HTMLElement | undefined; private iconIdElement: HighlightedLabel | undefined;
private readonly iconContainerWidth = 36; private readonly iconContainerWidth = 36;
private readonly iconContainerHeight = 32; private readonly iconContainerHeight = 32;
@@ -59,47 +69,61 @@ export class IconSelectBox extends Disposable {
inputBoxStyles: this.options.inputBoxStyles, 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.style.paddingRight = '10px';
iconsContainer.role = 'listbox';
iconsContainer.tabIndex = 0;
this.scrollableElement = disposables.add(new DomScrollableElement(iconsContainer, { this.scrollableElement = disposables.add(new DomScrollableElement(iconsContainer, {
useShadows: false, useShadows: false,
horizontal: ScrollbarVisibility.Hidden, horizontal: ScrollbarVisibility.Hidden,
})); }));
dom.append(iconSelectBoxContainer, this.scrollableElement.getDomNode()); 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()); 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(); this.scrollableElement.scanDomNode();
disposables.add(this.inputBox.onDidChange(value => { disposables.add(this.inputBox.onDidChange(value => {
const icons = this.options.icons.filter(icon => { const icons = [], matches = [];
return this.matchesContiguous(value, icon.id); for (const icon of this.options.icons) {
}); const match = this.matchesContiguous(value, icon.id);
iconsDisposables.value = this.renderIcons(icons, iconsContainer); if (match) {
icons.push(icon);
matches.push(match);
}
}
iconsDisposables.value = this.renderIcons(icons, matches, iconsContainer);
this.scrollableElement?.scanDomNode(); 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; return disposables;
} }
private renderIcons(icons: ThemeIcon[], container: HTMLElement): IDisposable { private renderIcons(icons: ThemeIcon[], matches: IMatch[][], container: HTMLElement): IDisposable {
const disposables = new DisposableStore(); const disposables = new DisposableStore();
dom.clearNode(container); dom.clearNode(container);
const focusedIcon = this.renderedIcons[this.focusedItemIndex]?.[0]; const focusedIcon = this.renderedIcons[this.focusedItemIndex]?.icon;
let focusedIconIndex = 0; let focusedIconIndex = 0;
const renderedIcons: [ThemeIcon, HTMLElement][] = []; const renderedIcons: IRenderedIconItem[] = [];
if (icons.length) { if (icons.length) {
for (let index = 0; index < icons.length; index++) { for (let index = 0; index < icons.length; index++) {
const icon = icons[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.width = `${this.iconContainerWidth}px`;
iconContainer.style.height = `${this.iconContainerHeight}px`; iconContainer.style.height = `${this.iconContainerHeight}px`;
iconContainer.tabIndex = -1;
iconContainer.role = 'button';
iconContainer.title = icon.id; 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))); 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) => { disposables.add(dom.addDisposableListener(iconContainer, dom.EventType.CLICK, (e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -129,17 +153,30 @@ export class IconSelectBox extends Disposable {
private focusIcon(index: number): void { private focusIcon(index: number): void {
const existing = this.renderedIcons[this.focusedItemIndex]; const existing = this.renderedIcons[this.focusedItemIndex];
if (existing) { if (existing) {
existing[1].classList.remove('focused'); existing.element.classList.remove('focused');
} }
this.focusedItemIndex = index; this.focusedItemIndex = index;
const icon = this.renderedIcons[index]?.[1]; const renderedItem = this.renderedIcons[index];
if (icon) {
icon.classList.add('focused'); 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) { 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); this.reveal(index);
@@ -152,16 +189,16 @@ export class IconSelectBox extends Disposable {
if (index < 0 || index >= this.renderedIcons.length) { if (index < 0 || index >= this.renderedIcons.length) {
return; return;
} }
const icon = this.renderedIcons[index][1]; const element = this.renderedIcons[index].element;
if (!icon) { if (!element) {
return; return;
} }
const { height } = this.scrollableElement.getScrollDimensions(); const { height } = this.scrollableElement.getScrollDimensions();
const { scrollTop } = this.scrollableElement.getScrollPosition(); const { scrollTop } = this.scrollableElement.getScrollPosition();
if (icon.offsetTop + this.iconContainerHeight > scrollTop + height) { if (element.offsetTop + this.iconContainerHeight > scrollTop + height) {
this.scrollableElement.setScrollPosition({ scrollTop: icon.offsetTop + this.iconContainerHeight - height }); this.scrollableElement.setScrollPosition({ scrollTop: element.offsetTop + this.iconContainerHeight - height });
} else if (icon.offsetTop < scrollTop) { } else if (element.offsetTop < scrollTop) {
this.scrollableElement.setScrollPosition({ scrollTop: icon.offsetTop }); this.scrollableElement.setScrollPosition({ scrollTop: element.offsetTop });
} }
} }
@@ -185,8 +222,8 @@ export class IconSelectBox extends Disposable {
const extraSpace = iconsContainerWidth % this.iconContainerWidth; const extraSpace = iconsContainerWidth % this.iconContainerWidth;
const margin = Math.floor(extraSpace / this.numberOfElementsPerRow); const margin = Math.floor(extraSpace / this.numberOfElementsPerRow);
for (const [, icon] of this.renderedIcons) { for (const { element } of this.renderedIcons) {
icon.style.marginRight = `${margin}px`; element.style.marginRight = `${margin}px`;
} }
if (this.scrollableElement) { if (this.scrollableElement) {
@@ -204,7 +241,7 @@ export class IconSelectBox extends Disposable {
throw new Error(`Invalid index ${index}`); throw new Error(`Invalid index ${index}`);
} }
this.focusIcon(index); this.focusIcon(index);
this._onDidSelect.fire(this.renderedIcons[index][0]); this._onDidSelect.fire(this.renderedIcons[index].icon);
} }
focus(): void { focus(): void {
@@ -224,6 +261,7 @@ export class IconSelectBox extends Disposable {
let nextRowIndex = this.focusedItemIndex + this.numberOfElementsPerRow; let nextRowIndex = this.focusedItemIndex + this.numberOfElementsPerRow;
if (nextRowIndex >= this.renderedIcons.length) { if (nextRowIndex >= this.renderedIcons.length) {
nextRowIndex = (nextRowIndex + 1) % this.numberOfElementsPerRow; nextRowIndex = (nextRowIndex + 1) % this.numberOfElementsPerRow;
nextRowIndex = nextRowIndex >= this.renderedIcons.length ? 0 : nextRowIndex;
} }
this.focusIcon(nextRowIndex); this.focusIcon(nextRowIndex);
} }
@@ -233,13 +271,17 @@ export class IconSelectBox extends Disposable {
if (previousRowIndex < 0) { if (previousRowIndex < 0) {
const numberOfRows = Math.floor(this.renderedIcons.length / this.numberOfElementsPerRow); const numberOfRows = Math.floor(this.renderedIcons.length / this.numberOfElementsPerRow);
previousRowIndex = this.focusedItemIndex + (this.numberOfElementsPerRow * numberOfRows) - 1; 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); this.focusIcon(previousRowIndex);
} }
getFocusedIcon(): ThemeIcon { 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 { IIconSelectBoxOptions, IconSelectBox } from 'vs/base/browser/ui/icons/iconSelectBox';
import { KeyCode } from 'vs/base/common/keyCodes'; 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'; import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
export const WorkbenchIconSelectBoxFocusContextKey = new RawContextKey<boolean>('iconSelectBoxFocus', true); 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 { export class WorkbenchIconSelectBox extends IconSelectBox {
@@ -17,12 +20,25 @@ export class WorkbenchIconSelectBox extends IconSelectBox {
return WorkbenchIconSelectBox.focusedWidget; return WorkbenchIconSelectBox.focusedWidget;
} }
private readonly contextKeyService: IContextKeyService;
private readonly inputFocusContextKey: IContextKey<boolean>;
private readonly inputEmptyContextKey: IContextKey<boolean>;
constructor( constructor(
options: IIconSelectBoxOptions, options: IIconSelectBoxOptions,
@IContextKeyService contextKeyService: IContextKeyService @IContextKeyService contextKeyService: IContextKeyService
) { ) {
super(options); 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 { override focus(): void {
@@ -37,7 +53,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({
weight: KeybindingWeight.WorkbenchContrib, weight: KeybindingWeight.WorkbenchContrib,
when: WorkbenchIconSelectBoxFocusContextKey, when: WorkbenchIconSelectBoxFocusContextKey,
primary: KeyCode.UpArrow, primary: KeyCode.UpArrow,
handler: (accessor, arg2) => { handler: () => {
const selectBox = WorkbenchIconSelectBox.getFocusedWidget(); const selectBox = WorkbenchIconSelectBox.getFocusedWidget();
if (selectBox) { if (selectBox) {
selectBox.focusPreviousRow(); selectBox.focusPreviousRow();
@@ -50,7 +66,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({
weight: KeybindingWeight.WorkbenchContrib, weight: KeybindingWeight.WorkbenchContrib,
when: WorkbenchIconSelectBoxFocusContextKey, when: WorkbenchIconSelectBoxFocusContextKey,
primary: KeyCode.DownArrow, primary: KeyCode.DownArrow,
handler: (accessor, arg2) => { handler: () => {
const selectBox = WorkbenchIconSelectBox.getFocusedWidget(); const selectBox = WorkbenchIconSelectBox.getFocusedWidget();
if (selectBox) { if (selectBox) {
selectBox.focusNextRow(); selectBox.focusNextRow();
@@ -61,9 +77,9 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({
KeybindingsRegistry.registerCommandAndKeybindingRule({ KeybindingsRegistry.registerCommandAndKeybindingRule({
id: 'iconSelectBox.focusNext', id: 'iconSelectBox.focusNext',
weight: KeybindingWeight.WorkbenchContrib, weight: KeybindingWeight.WorkbenchContrib,
when: WorkbenchIconSelectBoxFocusContextKey, when: ContextKeyExpr.and(WorkbenchIconSelectBoxFocusContextKey, ContextKeyExpr.or(WorkbenchIconSelectBoxInputEmptyContextKey, WorkbenchIconSelectBoxInputFocusContextKey.toNegated())),
primary: KeyCode.RightArrow, primary: KeyCode.RightArrow,
handler: (accessor, arg2) => { handler: () => {
const selectBox = WorkbenchIconSelectBox.getFocusedWidget(); const selectBox = WorkbenchIconSelectBox.getFocusedWidget();
if (selectBox) { if (selectBox) {
selectBox.focusNext(); selectBox.focusNext();
@@ -74,9 +90,9 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({
KeybindingsRegistry.registerCommandAndKeybindingRule({ KeybindingsRegistry.registerCommandAndKeybindingRule({
id: 'iconSelectBox.focusPrevious', id: 'iconSelectBox.focusPrevious',
weight: KeybindingWeight.WorkbenchContrib, weight: KeybindingWeight.WorkbenchContrib,
when: WorkbenchIconSelectBoxFocusContextKey, when: ContextKeyExpr.and(WorkbenchIconSelectBoxFocusContextKey, ContextKeyExpr.or(WorkbenchIconSelectBoxInputEmptyContextKey, WorkbenchIconSelectBoxInputFocusContextKey.toNegated())),
primary: KeyCode.LeftArrow, primary: KeyCode.LeftArrow,
handler: (accessor, arg2) => { handler: () => {
const selectBox = WorkbenchIconSelectBox.getFocusedWidget(); const selectBox = WorkbenchIconSelectBox.getFocusedWidget();
if (selectBox) { if (selectBox) {
selectBox.focusPrevious(); selectBox.focusPrevious();
@@ -89,7 +105,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({
weight: KeybindingWeight.WorkbenchContrib, weight: KeybindingWeight.WorkbenchContrib,
when: WorkbenchIconSelectBoxFocusContextKey, when: WorkbenchIconSelectBoxFocusContextKey,
primary: KeyCode.Enter, primary: KeyCode.Enter,
handler: (accessor, arg2) => { handler: () => {
const selectBox = WorkbenchIconSelectBox.getFocusedWidget(); const selectBox = WorkbenchIconSelectBox.getFocusedWidget();
if (selectBox) { if (selectBox) {
selectBox.setSelection(selectBox.getFocus()[0]); selectBox.setSelection(selectBox.getFocus()[0]);