UNPKG

chrome-devtools-frontend

Version:
330 lines (295 loc) • 11 kB
// Copyright 2017 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable rulesdir/no-imperative-dom-api */ import type * as Common from '../../core/common/common.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Platform from '../../core/platform/platform.js'; import * as IconButton from '../components/icon_button/icon_button.js'; import * as VisualLogging from '../visual_logging/visual_logging.js'; import * as ARIAUtils from './ARIAUtils.js'; import {Size} from './Geometry.js'; import {AnchorBehavior, GlassPane, MarginBehavior, PointerEventsBehavior} from './GlassPane.js'; import {ListControl, type ListDelegate, ListMode} from './ListControl.js'; import {Events as ListModelEvents, type ItemsReplacedEvent, type ListModel} from './ListModel.js'; import softDropDownStyles from './softDropDown.css.js'; import softDropDownButtonStyles from './softDropDownButton.css.js'; import {createShadowRootWithCoreStyles} from './UIUtils.js'; const UIStrings = { /** *@description Placeholder text in Soft Drop Down */ noItemSelected: '(no item selected)', } as const; const str_ = i18n.i18n.registerUIStrings('ui/legacy/SoftDropDown.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export class SoftDropDown<T> implements ListDelegate<T> { private delegate: Delegate<T>; private selectedItem: T|null; private readonly model: ListModel<T>; private placeholderText: Common.UIString.LocalizedString; element: HTMLButtonElement; private titleElement: HTMLElement; private readonly glassPane: GlassPane; private list: ListControl<T>; private rowHeight: number; private width: number; private listWasShowing200msAgo: boolean; constructor(model: ListModel<T>, delegate: Delegate<T>, jslogContext?: string) { this.delegate = delegate; this.selectedItem = null; this.model = model; this.placeholderText = i18nString(UIStrings.noItemSelected); this.element = document.createElement('button'); if (jslogContext) { this.element.setAttribute( 'jslog', `${VisualLogging.dropDown().track({click: true, keydown: 'ArrowUp|ArrowDown|Enter'}).context(jslogContext)}`, ); } this.element.classList.add('soft-dropdown'); Platform.DOMUtilities.appendStyle(this.element, softDropDownButtonStyles); this.titleElement = this.element.createChild('span', 'title'); const dropdownArrowIcon = IconButton.Icon.create('triangle-down'); this.element.appendChild(dropdownArrowIcon); ARIAUtils.setExpanded(this.element, false); this.glassPane = new GlassPane(); this.glassPane.setMarginBehavior(MarginBehavior.NO_MARGIN); this.glassPane.setAnchorBehavior(AnchorBehavior.PREFER_BOTTOM); this.glassPane.setOutsideClickCallback(this.hide.bind(this)); this.glassPane.setPointerEventsBehavior(PointerEventsBehavior.BLOCKED_BY_GLASS_PANE); this.list = new ListControl(model, this, ListMode.EqualHeightItems); this.list.element.classList.add('item-list'); this.rowHeight = 36; this.width = 315; createShadowRootWithCoreStyles(this.glassPane.contentElement, { cssFile: softDropDownStyles, }).appendChild(this.list.element); ARIAUtils.markAsMenu(this.list.element); VisualLogging.setMappedParent(this.list.element, this.element); this.list.element.setAttribute( 'jslog', `${VisualLogging.menu().parent('mapped').track({resize: true, keydown: 'ArrowUp|ArrowDown|PageUp|PageDown'})}`); this.listWasShowing200msAgo = false; this.element.addEventListener('mousedown', event => { if (this.listWasShowing200msAgo) { this.hide(event); } else if (!this.element.disabled) { this.show(event); } }, false); this.element.addEventListener('keydown', this.onKeyDownButton.bind(this), false); this.list.element.addEventListener('keydown', this.onKeyDownList.bind(this), false); this.list.element.addEventListener('focusout', this.hide.bind(this), false); this.list.element.addEventListener('mousedown', event => event.consume(true), false); this.list.element.addEventListener('mouseup', event => { if (event.target === this.list.element) { return; } if (!this.listWasShowing200msAgo) { return; } this.selectHighlightedItem(); if (event.target instanceof Element && event.target?.parentElement) { // hide() will consume the mouseup event and click won't be triggered void VisualLogging.logClick(event.target.parentElement, event); } this.hide(event); }, false); model.addEventListener(ListModelEvents.ITEMS_REPLACED, this.itemsReplaced, this); } private show(event: Event): void { if (this.glassPane.isShowing()) { return; } this.glassPane.setContentAnchorBox(this.element.boxInWindow()); this.glassPane.show((this.element.ownerDocument)); this.list.element.focus(); ARIAUtils.setExpanded(this.element, true); this.updateGlasspaneSize(); if (this.selectedItem) { this.list.selectItem(this.selectedItem); } event.consume(true); window.setTimeout(() => { this.listWasShowing200msAgo = true; }, 200); } private updateGlasspaneSize(): void { const maxHeight = this.rowHeight * (Math.min(this.model.length, 9)); this.glassPane.setMaxContentSize(new Size(this.width, maxHeight)); this.list.viewportResized(); } private hide(event: Event): void { window.setTimeout(() => { this.listWasShowing200msAgo = false; }, 200); this.glassPane.hide(); this.list.selectItem(null); ARIAUtils.setExpanded(this.element, false); this.element.focus(); event.consume(true); } private onKeyDownButton(event: KeyboardEvent): void { let handled = false; switch (event.key) { case 'ArrowUp': this.show(event); this.list.selectItemNextPage(); handled = true; break; case 'ArrowDown': this.show(event); this.list.selectItemPreviousPage(); handled = true; break; case 'Enter': case ' ': this.show(event); handled = true; break; default: break; } if (handled) { event.consume(true); } } private onKeyDownList(event: KeyboardEvent): void { let handled = false; switch (event.key) { case 'ArrowLeft': handled = this.list.selectPreviousItem(false, false); break; case 'ArrowRight': handled = this.list.selectNextItem(false, false); break; case 'Home': for (let i = 0; i < this.model.length; i++) { if (this.isItemSelectable(this.model.at(i))) { this.list.selectItem(this.model.at(i)); handled = true; break; } } break; case 'End': for (let i = this.model.length - 1; i >= 0; i--) { if (this.isItemSelectable(this.model.at(i))) { this.list.selectItem(this.model.at(i)); handled = true; break; } } break; case 'Escape': this.hide(event); handled = true; break; case 'Tab': case 'Enter': case ' ': this.selectHighlightedItem(); this.hide(event); handled = true; break; default: if (event.key.length === 1) { const selectedIndex = this.list.selectedIndex(); const letter = event.key.toUpperCase(); for (let i = 0; i < this.model.length; i++) { const item = this.model.at((selectedIndex + i + 1) % this.model.length); if (this.delegate.titleFor(item).toUpperCase().startsWith(letter)) { this.list.selectItem(item); break; } } handled = true; } break; } if (handled) { event.consume(true); } } setWidth(width: number): void { this.width = width; this.updateGlasspaneSize(); } setRowHeight(rowHeight: number): void { this.rowHeight = rowHeight; } setPlaceholderText(text: Common.UIString.LocalizedString): void { this.placeholderText = text; if (!this.selectedItem) { this.titleElement.textContent = this.placeholderText; } } private itemsReplaced(event: Common.EventTarget.EventTargetEvent<ItemsReplacedEvent<T>>): void { const {removed} = event.data; if (this.selectedItem && removed.indexOf(this.selectedItem) !== -1) { this.selectedItem = null; this.selectHighlightedItem(); } this.updateGlasspaneSize(); } getSelectedItem(): T|null { return this.selectedItem; } selectItem(item: T|null): void { this.selectedItem = item; if (this.selectedItem) { this.titleElement.textContent = this.delegate.titleFor(this.selectedItem); } else { this.titleElement.textContent = this.placeholderText; } this.delegate.itemSelected(this.selectedItem); } createElementForItem(item: T): Element { const element = document.createElement('div'); element.classList.add('item'); element.addEventListener('mousemove', e => { if ((e.movementX || e.movementY) && this.delegate.isItemSelectable(item)) { this.list.selectItem(item, false, /* Don't scroll */ true); } }); element.classList.toggle('disabled', !this.delegate.isItemSelectable(item)); element.classList.toggle('highlighted', this.list.selectedItem() === item); ARIAUtils.markAsMenuItem(element); element.appendChild(this.delegate.createElementForItem(item)); return element; } heightForItem(_item: T): number { return this.rowHeight; } isItemSelectable(item: T): boolean { return this.delegate.isItemSelectable(item); } selectedItemChanged(from: T|null, to: T|null, fromElement: Element|null, toElement: Element|null): void { if (fromElement) { fromElement.classList.remove('highlighted'); } if (toElement) { toElement.classList.add('highlighted'); } ARIAUtils.setActiveDescendant(this.list.element, toElement); this.delegate.highlightedItemChanged( from, to, fromElement?.firstElementChild ?? null, toElement?.firstElementChild ?? null); } updateSelectedItemARIA(_fromElement: Element|null, _toElement: Element|null): boolean { return false; } private selectHighlightedItem(): void { this.selectItem(this.list.selectedItem()); } refreshItem(item: T): void { this.list.refreshItem(item); } } export interface Delegate<T> { titleFor(item: T): string; createElementForItem(item: T): Element; isItemSelectable(item: T): boolean; itemSelected(item: T|null): void; highlightedItemChanged(from: T|null, to: T|null, fromElement: Element|null, toElement: Element|null): void; }