Path: blob/main/src/vs/base/browser/ui/icons/iconSelectBox.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './iconSelectBox.css';6import * as dom from '../../dom.js';7import { alert } from '../aria/aria.js';8import { IInputBoxStyles, InputBox } from '../inputbox/inputBox.js';9import { DomScrollableElement } from '../scrollbar/scrollableElement.js';10import { Emitter } from '../../../common/event.js';11import { IDisposable, DisposableStore, Disposable, MutableDisposable } from '../../../common/lifecycle.js';12import { ThemeIcon } from '../../../common/themables.js';13import { localize } from '../../../../nls.js';14import { IMatch } from '../../../common/filters.js';15import { ScrollbarVisibility } from '../../../common/scrollable.js';16import { HighlightedLabel } from '../highlightedlabel/highlightedLabel.js';1718export interface IIconSelectBoxOptions {19readonly icons: ThemeIcon[];20readonly inputBoxStyles: IInputBoxStyles;21readonly showIconInfo?: boolean;22}2324interface IRenderedIconItem {25readonly icon: ThemeIcon;26readonly element: HTMLElement;27readonly highlightMatches?: IMatch[];28}2930export class IconSelectBox extends Disposable {3132private static InstanceCount = 0;33readonly domId = `icon_select_box_id_${++IconSelectBox.InstanceCount}`;3435readonly domNode: HTMLElement;3637private _onDidSelect = this._register(new Emitter<ThemeIcon>());38readonly onDidSelect = this._onDidSelect.event;3940private renderedIcons: IRenderedIconItem[] = [];4142private focusedItemIndex: number = 0;43private numberOfElementsPerRow: number = 1;4445protected inputBox: InputBox | undefined;46private scrollableElement: DomScrollableElement | undefined;47private iconsContainer: HTMLElement | undefined;48private iconIdElement: HighlightedLabel | undefined;49private readonly iconContainerWidth = 36;50private readonly iconContainerHeight = 36;5152constructor(53private readonly options: IIconSelectBoxOptions,54) {55super();56this.domNode = dom.$('.icon-select-box');57this._register(this.create());58}5960private create(): IDisposable {61const disposables = new DisposableStore();6263const iconSelectBoxContainer = dom.append(this.domNode, dom.$('.icon-select-box-container'));64iconSelectBoxContainer.style.margin = '10px 15px';6566const iconSelectInputContainer = dom.append(iconSelectBoxContainer, dom.$('.icon-select-input-container'));67iconSelectInputContainer.style.paddingBottom = '10px';68this.inputBox = disposables.add(new InputBox(iconSelectInputContainer, undefined, {69placeholder: localize('iconSelect.placeholder', "Search icons"),70inputBoxStyles: this.options.inputBoxStyles,71}));7273const iconsContainer = this.iconsContainer = dom.$('.icon-select-icons-container', { id: `${this.domId}_icons` });74iconsContainer.role = 'listbox';75iconsContainer.tabIndex = 0;76this.scrollableElement = disposables.add(new DomScrollableElement(iconsContainer, {77useShadows: false,78horizontal: ScrollbarVisibility.Hidden,79}));80dom.append(iconSelectBoxContainer, this.scrollableElement.getDomNode());8182if (this.options.showIconInfo) {83this.iconIdElement = this._register(new HighlightedLabel(dom.append(dom.append(iconSelectBoxContainer, dom.$('.icon-select-id-container')), dom.$('.icon-select-id-label'))));84}8586const iconsDisposables = disposables.add(new MutableDisposable());87iconsDisposables.value = this.renderIcons(this.options.icons, [], iconsContainer);88this.scrollableElement.scanDomNode();8990disposables.add(this.inputBox.onDidChange(value => {91const icons = [], matches = [];92for (const icon of this.options.icons) {93const match = this.matchesContiguous(value, icon.id);94if (match) {95icons.push(icon);96matches.push(match);97}98}99if (icons.length) {100iconsDisposables.value = this.renderIcons(icons, matches, iconsContainer);101this.scrollableElement?.scanDomNode();102}103}));104105this.inputBox.inputElement.role = 'combobox';106this.inputBox.inputElement.ariaHasPopup = 'menu';107this.inputBox.inputElement.ariaAutoComplete = 'list';108this.inputBox.inputElement.ariaExpanded = 'true';109this.inputBox.inputElement.setAttribute('aria-controls', iconsContainer.id);110111return disposables;112}113114private renderIcons(icons: ThemeIcon[], matches: IMatch[][], container: HTMLElement): IDisposable {115const disposables = new DisposableStore();116dom.clearNode(container);117const focusedIcon = this.renderedIcons[this.focusedItemIndex]?.icon;118let focusedIconIndex = 0;119const renderedIcons: IRenderedIconItem[] = [];120if (icons.length) {121for (let index = 0; index < icons.length; index++) {122const icon = icons[index];123const iconContainer = dom.append(container, dom.$('.icon-container', { id: `${this.domId}_icons_${index}` }));124iconContainer.style.width = `${this.iconContainerWidth}px`;125iconContainer.style.height = `${this.iconContainerHeight}px`;126iconContainer.title = icon.id;127iconContainer.role = 'button';128iconContainer.setAttribute('aria-setsize', `${icons.length}`);129iconContainer.setAttribute('aria-posinset', `${index + 1}`);130dom.append(iconContainer, dom.$(ThemeIcon.asCSSSelector(icon)));131renderedIcons.push({ icon, element: iconContainer, highlightMatches: matches[index] });132133disposables.add(dom.addDisposableListener(iconContainer, dom.EventType.CLICK, (e: MouseEvent) => {134e.stopPropagation();135this.setSelection(index);136}));137138if (icon === focusedIcon) {139focusedIconIndex = index;140}141}142} else {143const noResults = localize('iconSelect.noResults', "No results");144dom.append(container, dom.$('.icon-no-results', undefined, noResults));145alert(noResults);146}147148this.renderedIcons.splice(0, this.renderedIcons.length, ...renderedIcons);149this.focusIcon(focusedIconIndex);150151return disposables;152}153154private focusIcon(index: number): void {155const existing = this.renderedIcons[this.focusedItemIndex];156if (existing) {157existing.element.classList.remove('focused');158}159160this.focusedItemIndex = index;161const renderedItem = this.renderedIcons[index];162163if (renderedItem) {164renderedItem.element.classList.add('focused');165}166167if (this.inputBox) {168if (renderedItem) {169this.inputBox.inputElement.setAttribute('aria-activedescendant', renderedItem.element.id);170} else {171this.inputBox.inputElement.removeAttribute('aria-activedescendant');172}173}174175if (this.iconIdElement) {176if (renderedItem) {177this.iconIdElement.set(renderedItem.icon.id, renderedItem.highlightMatches);178} else {179this.iconIdElement.set('');180}181}182183this.reveal(index);184}185186private reveal(index: number): void {187if (!this.scrollableElement) {188return;189}190if (index < 0 || index >= this.renderedIcons.length) {191return;192}193const element = this.renderedIcons[index].element;194if (!element) {195return;196}197const { height } = this.scrollableElement.getScrollDimensions();198const { scrollTop } = this.scrollableElement.getScrollPosition();199if (element.offsetTop + this.iconContainerHeight > scrollTop + height) {200this.scrollableElement.setScrollPosition({ scrollTop: element.offsetTop + this.iconContainerHeight - height });201} else if (element.offsetTop < scrollTop) {202this.scrollableElement.setScrollPosition({ scrollTop: element.offsetTop });203}204}205206private matchesContiguous(word: string, wordToMatchAgainst: string): IMatch[] | null {207const matchIndex = wordToMatchAgainst.toLowerCase().indexOf(word.toLowerCase());208if (matchIndex !== -1) {209return [{ start: matchIndex, end: matchIndex + word.length }];210}211return null;212}213214layout(dimension: dom.Dimension): void {215this.domNode.style.width = `${dimension.width}px`;216this.domNode.style.height = `${dimension.height}px`;217218const iconsContainerWidth = dimension.width - 30;219this.numberOfElementsPerRow = Math.floor(iconsContainerWidth / this.iconContainerWidth);220if (this.numberOfElementsPerRow === 0) {221throw new Error('Insufficient width');222}223224const extraSpace = iconsContainerWidth % this.iconContainerWidth;225const iconElementMargin = Math.floor(extraSpace / this.numberOfElementsPerRow);226for (const { element } of this.renderedIcons) {227element.style.marginRight = `${iconElementMargin}px`;228}229230const containerPadding = extraSpace % this.numberOfElementsPerRow;231if (this.iconsContainer) {232this.iconsContainer.style.paddingLeft = `${Math.floor(containerPadding / 2)}px`;233this.iconsContainer.style.paddingRight = `${Math.ceil(containerPadding / 2)}px`;234}235236if (this.scrollableElement) {237this.scrollableElement.getDomNode().style.height = `${this.iconIdElement ? dimension.height - 80 : dimension.height - 40}px`;238this.scrollableElement.scanDomNode();239}240}241242getFocus(): number[] {243return [this.focusedItemIndex];244}245246setSelection(index: number): void {247if (index < 0 || index >= this.renderedIcons.length) {248throw new Error(`Invalid index ${index}`);249}250this.focusIcon(index);251this._onDidSelect.fire(this.renderedIcons[index].icon);252}253254clearInput(): void {255if (this.inputBox) {256this.inputBox.value = '';257}258}259260focus(): void {261this.inputBox?.focus();262this.focusIcon(0);263}264265focusNext(): void {266this.focusIcon((this.focusedItemIndex + 1) % this.renderedIcons.length);267}268269focusPrevious(): void {270this.focusIcon((this.focusedItemIndex - 1 + this.renderedIcons.length) % this.renderedIcons.length);271}272273focusNextRow(): void {274let nextRowIndex = this.focusedItemIndex + this.numberOfElementsPerRow;275if (nextRowIndex >= this.renderedIcons.length) {276nextRowIndex = (nextRowIndex + 1) % this.numberOfElementsPerRow;277nextRowIndex = nextRowIndex >= this.renderedIcons.length ? 0 : nextRowIndex;278}279this.focusIcon(nextRowIndex);280}281282focusPreviousRow(): void {283let previousRowIndex = this.focusedItemIndex - this.numberOfElementsPerRow;284if (previousRowIndex < 0) {285const numberOfRows = Math.floor(this.renderedIcons.length / this.numberOfElementsPerRow);286previousRowIndex = this.focusedItemIndex + (this.numberOfElementsPerRow * numberOfRows) - 1;287previousRowIndex = previousRowIndex < 0288? this.renderedIcons.length - 1289: previousRowIndex >= this.renderedIcons.length290? previousRowIndex - this.numberOfElementsPerRow291: previousRowIndex;292}293this.focusIcon(previousRowIndex);294}295296getFocusedIcon(): ThemeIcon {297return this.renderedIcons[this.focusedItemIndex].icon;298}299300}301302303