diff --git a/src/vs/base/browser/ui/icons/iconSelectBox.css b/src/vs/base/browser/ui/icons/iconSelectBox.css index a5a9b767478..4e2278d04bc 100644 --- a/src/vs/base/browser/ui/icons/iconSelectBox.css +++ b/src/vs/base/browser/ui/icons/iconSelectBox.css @@ -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); +} diff --git a/src/vs/base/browser/ui/icons/iconSelectBox.ts b/src/vs/base/browser/ui/icons/iconSelectBox.ts index afc47f018d0..c592c37d3f7 100644 --- a/src/vs/base/browser/ui/icons/iconSelectBox.ts +++ b/src/vs/base/browser/ui/icons/iconSelectBox.ts @@ -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()); 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; } } diff --git a/src/vs/workbench/browser/iconSelectBox.ts b/src/vs/workbench/browser/iconSelectBox.ts index 0e38a01d30f..e74f52687cd 100644 --- a/src/vs/workbench/browser/iconSelectBox.ts +++ b/src/vs/workbench/browser/iconSelectBox.ts @@ -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('iconSelectBoxFocus', true); +export const WorkbenchIconSelectBoxInputFocusContextKey = new RawContextKey('iconSelectBoxInputFocus', true); +export const WorkbenchIconSelectBoxInputEmptyContextKey = new RawContextKey('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; + private readonly inputEmptyContextKey: IContextKey; + 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]);