mirror of
https://github.com/microsoft/vscode.git
synced 2025-12-20 02:08:47 +00:00
accessibility improvements (#193571)
This commit is contained in:
committed by
GitHub
parent
1da15ea905
commit
3503252879
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user