UNPKG

chrome-devtools-frontend

Version:
472 lines (411 loc) • 14.9 kB
// Copyright 2023 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-lit-render-outside-of-view */ import * as Platform from '../../../core/platform/platform.js'; import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js'; import * as RenderCoordinator from '../../../ui/components/render_coordinator/render_coordinator.js'; import * as Lit from '../../../ui/lit/lit.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import * as Dialogs from '../dialogs/dialogs.js'; import { MenuGroup, type MenuItemSelectedEvent, type MenuItemValue, } from './Menu.js'; import selectMenuStyles from './selectMenu.css.js'; import selectMenuButtonStyles from './selectMenuButton.css.js'; const {html} = Lit; export interface SelectMenuData { /** * Determines where the dialog with the menu will show relative to * the show button. * Defaults to Bottom. */ position: Dialogs.Dialog.DialogVerticalPosition; /** * Determines where the dialog with the menu will show horizontally * relative to the show button. * Defaults to Auto */ horizontalAlignment: Dialogs.Dialog.DialogHorizontalAlignment; /** * The title of the menu button. Can be either a string or a function * that returns a Lit template. * If not set, the title of the button will default to the selected * item's text. */ buttonTitle: string|TitleCallback; /** * Determines if an arrow, pointing to the opposite side of * the dialog, is shown at the end of the button. * Defaults to false. */ showArrow: boolean; /** * Determines if the component is formed by two buttons: * one to open the meny and another that triggers a * selectmenusidebuttonclickEvent. The RecordMenu instance of * the component is an example of this use case. * Defaults to false. */ sideButton: boolean; /** * Whether the menu button is disabled. * Defaults to false. */ disabled: boolean; /** * Determines if dividing lines between the menu's options * are shown. */ showDivider: boolean; /** * Determines if the selected item is marked using a checkmark. * Defaults to true. */ showSelectedItem: boolean; /** * Specifies a context for the visual element. */ jslogContext: string; } type TitleCallback = () => Lit.TemplateResult; const deployMenuArrow = new URL('../../../Images/triangle-down.svg', import.meta.url).toString(); /** * @deprecated use `<select>` instead. */ export class SelectMenu extends HTMLElement { readonly #shadow = this.attachShadow({mode: 'open'}); #button: SelectMenuButton|null = null; #open = false; #props: SelectMenuData = { buttonTitle: '', position: Dialogs.Dialog.DialogVerticalPosition.BOTTOM, horizontalAlignment: Dialogs.Dialog.DialogHorizontalAlignment.AUTO, showArrow: false, sideButton: false, showDivider: false, disabled: false, showSelectedItem: true, jslogContext: '', }; get buttonTitle(): string|TitleCallback { return this.#props.buttonTitle; } set buttonTitle(buttonTitle: string|TitleCallback) { this.#props.buttonTitle = buttonTitle; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } get position(): Dialogs.Dialog.DialogVerticalPosition { return this.#props.position; } set position(position: Dialogs.Dialog.DialogVerticalPosition) { this.#props.position = position; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } get horizontalAlignment(): Dialogs.Dialog.DialogHorizontalAlignment { return this.#props.horizontalAlignment; } set horizontalAlignment(horizontalAlignment: Dialogs.Dialog.DialogHorizontalAlignment) { this.#props.horizontalAlignment = horizontalAlignment; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } get showArrow(): boolean { return this.#props.showArrow; } set showArrow(showArrow: boolean) { this.#props.showArrow = showArrow; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } get sideButton(): boolean { return this.#props.sideButton; } set sideButton(sideButton: boolean) { this.#props.sideButton = sideButton; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } get disabled(): boolean { return this.#props.disabled; } set disabled(disabled: boolean) { this.#props.disabled = disabled; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } get showDivider(): boolean { return this.#props.showDivider; } set showDivider(showDivider: boolean) { this.#props.showDivider = showDivider; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } get showSelectedItem(): boolean { return this.#props.showSelectedItem; } set showSelectedItem(showSelectedItem: boolean) { this.#props.showSelectedItem = showSelectedItem; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } get jslogContext(): string { return this.#props.jslogContext; } set jslogContext(jslogContext: string) { this.#props.jslogContext = jslogContext; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } #getButton(): SelectMenuButton { if (!this.#button) { this.#button = this.#shadow.querySelector('devtools-select-menu-button'); if (!this.#button) { throw new Error('Arrow not found'); } } return this.#button; } #showMenu(): void { this.#open = true; this.setAttribute('has-open-dialog', 'has-open-dialog'); void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } override click(): void { this.#getButton().click(); } #sideButtonClicked(): void { this.dispatchEvent(new SelectMenuSideButtonClickEvent()); } #getButtonText(): Lit.TemplateResult|string { return this.buttonTitle instanceof Function ? this.buttonTitle() : this.buttonTitle; } #renderButton(): Lit.TemplateResult { const buttonLabel = this.#getButtonText(); if (!this.sideButton) { // clang-format off /* eslint-disable rulesdir/no-deprecated-component-usages */ return html` <devtools-select-menu-button @selectmenubuttontrigger=${this.#showMenu} .open=${this.#open} .showArrow=${this.showArrow} .arrowDirection=${this.position} .disabled=${this.disabled} .jslogContext=${this.jslogContext}> ${buttonLabel} </devtools-select-menu-button> `; /* eslint-enable rulesdir/no-deprecated-component-usages */ // clang-format on } // clang-format off /* eslint-disable rulesdir/no-deprecated-component-usages */ return html` <button id="side-button" @click=${this.#sideButtonClicked} ?disabled=${this.disabled}> ${buttonLabel} </button> <devtools-select-menu-button @click=${this.#showMenu} @selectmenubuttontrigger=${this.#showMenu} .singleArrow=${true} .open=${this.#open} .showArrow=${true} .arrowDirection=${this.position} .disabled=${this.disabled}> </devtools-select-menu-button> `; /* eslint-enable rulesdir/no-deprecated-component-usages */ // clang-format on } #onMenuClose(evt?: Dialogs.Dialog.ClickOutsideDialogEvent): void { if (evt) { evt.stopImmediatePropagation(); } void RenderCoordinator.write(() => { this.removeAttribute('has-open-dialog'); }); this.#open = false; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } #onItemSelected(evt: MenuItemSelectedEvent): void { this.dispatchEvent(new SelectMenuItemSelectedEvent(evt.itemValue)); } async #render(): Promise<void> { if (!ComponentHelpers.ScheduledRender.isScheduledRender(this)) { throw new Error('SelectMenu render was not scheduled'); } // clang-format off Lit.render(html` <style>${selectMenuStyles}</style> <devtools-menu @menucloserequest=${this.#onMenuClose} @menuitemselected=${this.#onItemSelected} .position=${this.position} .origin=${this} .showDivider=${this.showDivider} .showSelectedItem=${this.showSelectedItem} .open=${this.#open} .getConnectorCustomXPosition=${null}> <slot></slot> </devtools-menu> ${this.#renderButton()}`, this.#shadow, {host: this}); // clang-format on } } export interface SelectMenuButtonData { showArrow: boolean; arrowDirection: Dialogs.Dialog.DialogVerticalPosition; disabled: boolean; singleArrow: boolean; jslogContext: string; } export class SelectMenuButton extends HTMLElement { readonly #shadow = this.attachShadow({mode: 'open'}); #showButton: HTMLButtonElement|null = null; connectedCallback(): void { this.style.setProperty('--deploy-menu-arrow', `url(${deployMenuArrow})`); void RenderCoordinator.write(() => { switch (this.arrowDirection) { case Dialogs.Dialog.DialogVerticalPosition.AUTO: case Dialogs.Dialog.DialogVerticalPosition.TOP: { this.style.setProperty('--arrow-angle', '180deg'); break; } case Dialogs.Dialog.DialogVerticalPosition.BOTTOM: { this.style.setProperty('--arrow-angle', '0deg'); break; } default: Platform.assertNever(this.arrowDirection, `Unknown position type: ${this.arrowDirection}`); } }); } #props: SelectMenuButtonData = { showArrow: false, arrowDirection: Dialogs.Dialog.DialogVerticalPosition.BOTTOM, disabled: false, singleArrow: false, jslogContext: '', }; get showArrow(): boolean { return this.#props.showArrow; } set showArrow(showArrow: boolean) { this.#props.showArrow = showArrow; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } get arrowDirection(): Dialogs.Dialog.DialogVerticalPosition { return this.#props.arrowDirection; } set arrowDirection(arrowDirection: Dialogs.Dialog.DialogVerticalPosition) { this.#props.arrowDirection = arrowDirection; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } get disabled(): boolean { return this.#props.disabled; } set disabled(disabled: boolean) { this.#props.disabled = disabled; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } set open(open: boolean) { void RenderCoordinator.write(() => { this.#getShowButton()?.setAttribute('aria-expanded', String(open)); }); } set singleArrow(singleArrow: boolean) { this.#props.singleArrow = singleArrow; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } get jslogContext(): string { return this.#props.jslogContext; } set jslogContext(jslogContext: string) { this.#props.jslogContext = jslogContext; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } override click(): void { this.#getShowButton()?.click(); } #getShowButton(): HTMLButtonElement|null { if (!this.#showButton) { this.#showButton = this.#shadow.querySelector('button'); } return this.#showButton; } #handleButtonKeyDown(evt: KeyboardEvent): void { const key = evt.key; const shouldShowDialogBelow = this.arrowDirection === Dialogs.Dialog.DialogVerticalPosition.BOTTOM && key === Platform.KeyboardUtilities.ArrowKey.DOWN; const shouldShowDialogAbove = this.arrowDirection === Dialogs.Dialog.DialogVerticalPosition.TOP && key === Platform.KeyboardUtilities.ArrowKey.UP; const isEnter = key === Platform.KeyboardUtilities.ENTER_KEY; const isSpace = evt.code === 'Space'; if (shouldShowDialogBelow || shouldShowDialogAbove || isEnter || isSpace) { this.dispatchEvent(new SelectMenuButtonTriggerEvent()); evt.preventDefault(); } } #handleClick(): void { this.dispatchEvent(new SelectMenuButtonTriggerEvent()); } async #render(): Promise<void> { if (!ComponentHelpers.ScheduledRender.isScheduledRender(this)) { throw new Error('SelectMenuItem render was not scheduled'); } const arrow = this.#props.showArrow ? html`<span id="arrow"></span>` : Lit.nothing; const classMap = {'single-arrow': this.#props.singleArrow}; // clang-format off const buttonTitle = html` <span id="button-label-wrapper"> <span id="label" ?witharrow=${this.showArrow} class=${Lit.Directives.classMap(classMap)}> <slot></slot> </span> ${arrow} </span>`; // clang-format off Lit.render(html` <style>${selectMenuButtonStyles}</style> <button aria-haspopup="true" aria-expanded="false" class="show" @keydown=${this.#handleButtonKeyDown} @click=${this.#handleClick} ?disabled=${this.disabled} jslog=${VisualLogging.dropDown(this.jslogContext)}> ${buttonTitle} </button>`, this.#shadow, { host: this }); // clang-format on } } customElements.define('devtools-select-menu', SelectMenu); customElements.define('devtools-select-menu-button', SelectMenuButton); declare global { interface HTMLElementTagNameMap { 'devtools-select-menu': SelectMenu; 'devtools-select-menu-button': SelectMenuButton; } interface HTMLElementEventMap { [SelectMenuItemSelectedEvent.eventName]: SelectMenuItemSelectedEvent; } } export class SelectMenuItemSelectedEvent extends Event { static readonly eventName = 'selectmenuselected'; constructor(public itemValue: SelectMenuItemValue) { super(SelectMenuItemSelectedEvent.eventName, {bubbles: true, composed: true}); } } export class SelectMenuSideButtonClickEvent extends Event { static readonly eventName = 'selectmenusidebuttonclick'; constructor() { super(SelectMenuSideButtonClickEvent.eventName, {bubbles: true, composed: true}); } } export class SelectMenuButtonTriggerEvent extends Event { static readonly eventName = 'selectmenubuttontrigger'; constructor() { super(SelectMenuButtonTriggerEvent.eventName, {bubbles: true, composed: true}); } } // Exported artifacts used in this component and that belong to the Menu are // renamed to only make reference to the SelectMenu. This way, the Menu API // doesn't have to be used in SelectMenu usages and the SelectMenu implementation // can remain transparent to its users. export type SelectMenuItemValue = MenuItemValue; export {MenuGroup as SelectMenuGroup};