| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| /* eslint-disable @devtools/no-lit-render-outside-of-view, @devtools/enforce-custom-element-definitions-location */ |
| |
| 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 menuStyles from './menu.css.js'; |
| import menuGroupStyles from './menuGroup.css.js'; |
| import menuItemStyles from './menuItem.css.js'; |
| |
| const {html, Directives: {ref}} = Lit; |
| |
| export interface MenuData { |
| /** |
| * Whether the menu is open. |
| */ |
| open: boolean; |
| /** |
| * Determines where the dialog with the menu will show relative to |
| * the menu's origin. |
| * Defaults to Bottom. |
| */ |
| position: Dialogs.Dialog.DialogVerticalPosition; |
| /** |
| * Position or point the dialog is shown relative to. |
| */ |
| origin: Dialogs.Dialog.DialogOrigin; |
| /** |
| * Determines if dividing lines between the menu's options |
| * are shown. |
| * Defaults to false. |
| */ |
| showDivider: boolean; |
| /** |
| * Determines if the selected item is marked using a checkmark. |
| * Defaults to true. |
| */ |
| showSelectedItem: boolean; |
| /** |
| * Determines where the dialog with the menu will show horizontally |
| * relative to the show button. |
| * Defaults to Auto |
| */ |
| horizontalAlignment: Dialogs.Dialog.DialogHorizontalAlignment; |
| /** |
| * Optional function used to the determine the x coordinate of the connector's |
| * end (tip of the triangle), relative to the viewport. If not defined, the x |
| * coordinate of the origin's center is used instead. |
| */ |
| getConnectorCustomXPosition: (() => number)|null; |
| } |
| |
| const selectedItemCheckmark = new URL('../../../Images/checkmark.svg', import.meta.url).toString(); |
| |
| export class Menu extends HTMLElement { |
| readonly #shadow = this.attachShadow({mode: 'open'}); |
| #dialog: Dialogs.Dialog.Dialog|null = null; |
| #itemIsFocused = false; |
| #props: MenuData = { |
| origin: null, |
| open: false, |
| position: Dialogs.Dialog.DialogVerticalPosition.AUTO, |
| showDivider: false, |
| showSelectedItem: true, |
| horizontalAlignment: Dialogs.Dialog.DialogHorizontalAlignment.AUTO, |
| getConnectorCustomXPosition: null, |
| }; |
| |
| get origin(): Dialogs.Dialog.DialogOrigin { |
| return this.#props.origin; |
| } |
| |
| set origin(origin: Dialogs.Dialog.DialogOrigin) { |
| this.#props.origin = origin; |
| void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); |
| } |
| |
| get open(): boolean { |
| return this.#props.open; |
| } |
| |
| set open(open: boolean) { |
| if (open === this.open) { |
| return; |
| } |
| this.#props.open = open; |
| this.toggleAttribute('has-open-dialog', this.open); |
| void this.#getDialog().setDialogVisible(this.open); |
| 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 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 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 getConnectorCustomXPosition(): (() => number)|null { |
| return this.#props.getConnectorCustomXPosition; |
| } |
| |
| set getConnectorCustomXPosition(connectorXPosition: (() => number)|null) { |
| this.#props.getConnectorCustomXPosition = connectorXPosition; |
| void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); |
| } |
| |
| connectedCallback(): void { |
| void RenderCoordinator.write(() => { |
| this.style.setProperty('--selected-item-check', `url(${selectedItemCheckmark})`); |
| this.style.setProperty('--menu-checkmark-width', this.#props.showSelectedItem ? '26px' : '0px'); |
| this.style.setProperty('--menu-checkmark-height', this.#props.showSelectedItem ? '12px' : '0px'); |
| const dividerLine = this.showDivider ? '1px var(--divider-line) solid' : 'none'; |
| this.style.setProperty('--override-divider-line', dividerLine); |
| }); |
| } |
| |
| #getDialog(): Dialogs.Dialog.Dialog { |
| if (!this.#dialog) { |
| throw new Error('Dialog not found'); |
| } |
| return this.#dialog; |
| } |
| |
| async #dialogDeployed(): Promise<void> { |
| await RenderCoordinator.write(() => { |
| this.setAttribute('has-open-dialog', 'has-open-dialog'); |
| // Focus the container so tha twe can capture key events. |
| const container = this.#shadow.querySelector('#container'); |
| if (!(container instanceof HTMLElement)) { |
| return; |
| } |
| container.focus(); |
| }); |
| } |
| |
| #focusFirstItem(): void { |
| this.#getFirstItem().focus(); |
| } |
| |
| #getFirstItem(): HTMLElement { |
| const defaultSlot = this.#shadow.querySelector('slot') as HTMLSlotElement; |
| const items = defaultSlot?.assignedElements(); |
| let firstItem = items[0]; |
| if (firstItem instanceof HTMLSlotElement) { |
| firstItem = firstItem?.assignedElements()[0]; |
| } |
| if (firstItem instanceof MenuGroup) { |
| const groupDefaultSlot = firstItem.shadowRoot?.querySelector('slot') as HTMLSlotElement; |
| firstItem = groupDefaultSlot?.assignedElements()[0]; |
| } |
| if (firstItem instanceof HTMLElement) { |
| return firstItem; |
| } |
| throw new Error('First item not found'); |
| } |
| |
| #handleItemClick(evt: MouseEvent): void { |
| const path = evt.composedPath(); |
| evt.stopPropagation(); |
| |
| // If the clicked item is an input element, do not follow the default behaviour. |
| if (path.find(element => element instanceof HTMLInputElement)) { |
| return; |
| } |
| const item = evt.composedPath().find(element => element instanceof MenuItem); |
| // Compare against MenuItem again to narrow the item's type. |
| if (!(item instanceof MenuItem)) { |
| return; |
| } |
| if (item.disabled) { |
| return; |
| } |
| this.#updateSelectedValue(item); |
| } |
| |
| #handleDialogKeyDown(evt: KeyboardEvent): void { |
| const key = evt.key; |
| evt.stopImmediatePropagation(); |
| let item: EventTarget|null|undefined = evt.target; |
| const path = evt.composedPath(); |
| const shouldFocusFirstItem = |
| key === Platform.KeyboardUtilities.ArrowKey.DOWN || key === Platform.KeyboardUtilities.ArrowKey.RIGHT; |
| if (!this.#itemIsFocused && shouldFocusFirstItem) { |
| this.#focusFirstItem(); |
| this.#itemIsFocused = true; |
| return; |
| } |
| if (!this.#itemIsFocused && key === Platform.KeyboardUtilities.ArrowKey.UP) { |
| this.#focusLastItem(); |
| this.#itemIsFocused = true; |
| return; |
| } |
| // The focused item could be nested inside the MenuItem, hence |
| // find the MenuItem item inside the event's composed path. |
| if (!(item instanceof MenuItem)) { |
| item = path.find(element => element instanceof MenuItem); |
| |
| // Compare against MenuItem again to narrow the item's type. |
| if (!(item instanceof MenuItem)) { |
| return; |
| } |
| } |
| if (Platform.KeyboardUtilities.keyIsArrowKey(key)) { |
| this.#handleArrowKeyNavigation(key, item); |
| } else if (key === 'Home') { |
| this.#handleHomeKeyDown(item); |
| } else if (key === 'End') { |
| this.#focusLastItem(); |
| } else if (key === 'Enter' || evt.code === 'Space') { |
| this.#updateSelectedValue(item); |
| } else if (key === 'Escape') { |
| evt.preventDefault(); |
| this.#closeDialog(); |
| } |
| } |
| |
| #updateSelectedValue(item: MenuItem): void { |
| if (item.value === '') { |
| return; |
| } |
| this.dispatchEvent(new MenuItemSelectedEvent(item.value)); |
| if (item.preventMenuCloseOnSelection) { |
| return; |
| } |
| this.#closeDialog(); |
| } |
| |
| #handleArrowKeyNavigation(key: Platform.KeyboardUtilities.ArrowKey, currentItem: MenuItem): void { |
| let nextSibling: Element|null = currentItem; |
| if (key === Platform.KeyboardUtilities.ArrowKey.DOWN) { |
| nextSibling = currentItem.nextElementSibling; |
| // Handle last item in a group and navigating down: |
| if (nextSibling === null && currentItem.parentElement instanceof MenuGroup) { |
| nextSibling = this.#firstItemInNextGroup(currentItem); |
| } |
| } else if (key === Platform.KeyboardUtilities.ArrowKey.UP) { |
| nextSibling = currentItem.previousElementSibling; |
| // Handle first item in a group and navigating up: |
| if (nextSibling === null && currentItem.parentElement instanceof MenuGroup) { |
| nextSibling = this.#lastItemInPreviousGroup(currentItem); |
| } |
| } |
| if (nextSibling instanceof MenuItem) { |
| nextSibling.focus(); |
| } |
| } |
| #firstItemInNextGroup(currentItem: MenuItem): MenuItem|null { |
| const parentElement = currentItem.parentElement; |
| if (!(parentElement instanceof MenuGroup)) { |
| return null; |
| } |
| const parentNextSibling = parentElement.nextElementSibling; |
| if (parentNextSibling instanceof MenuItem) { |
| return parentNextSibling; |
| } |
| if (!(parentNextSibling instanceof MenuGroup)) { |
| return null; |
| } |
| for (const child of parentNextSibling.children) { |
| if (child instanceof MenuItem) { |
| return child; |
| } |
| } |
| return null; |
| } |
| |
| #lastItemInPreviousGroup(currentItem: MenuItem): MenuItem|null { |
| const parentElement = currentItem.parentElement; |
| if (!(parentElement instanceof MenuGroup)) { |
| return null; |
| } |
| const parentPreviousSibling = parentElement.previousElementSibling; |
| if (parentPreviousSibling instanceof MenuItem) { |
| return parentPreviousSibling; |
| } |
| if (!(parentPreviousSibling instanceof MenuGroup)) { |
| return null; |
| } |
| if (parentPreviousSibling.lastElementChild instanceof MenuItem) { |
| return parentPreviousSibling.lastElementChild; |
| } |
| return null; |
| } |
| |
| #handleHomeKeyDown(currentItem: MenuItem): void { |
| let topMenuPart: Element|null = currentItem; |
| if (currentItem.parentElement instanceof MenuGroup) { |
| topMenuPart = currentItem.parentElement; |
| } |
| while (topMenuPart?.previousElementSibling) { |
| topMenuPart = topMenuPart?.previousElementSibling; |
| } |
| if (topMenuPart instanceof MenuItem) { |
| topMenuPart.focus(); |
| return; |
| } |
| for (const child of topMenuPart.children) { |
| if (child instanceof MenuItem) { |
| child.focus(); |
| return; |
| } |
| } |
| } |
| |
| #focusLastItem(): void { |
| const currentItem = this.#getFirstItem(); |
| let lastMenuPart: Element|null = currentItem; |
| if (currentItem.parentElement instanceof MenuGroup) { |
| lastMenuPart = currentItem.parentElement; |
| } |
| while (lastMenuPart?.nextElementSibling) { |
| lastMenuPart = lastMenuPart?.nextElementSibling; |
| } |
| if (lastMenuPart instanceof MenuItem) { |
| lastMenuPart.focus(); |
| return; |
| } |
| if (lastMenuPart instanceof MenuGroup && lastMenuPart.lastElementChild instanceof MenuItem) { |
| lastMenuPart.lastElementChild.focus(); |
| } |
| } |
| |
| #closeDialog(evt?: Dialogs.Dialog.ClickOutsideDialogEvent): void { |
| if (evt) { |
| evt.stopImmediatePropagation(); |
| } |
| this.dispatchEvent(new MenuCloseRequest()); |
| void this.#getDialog().setDialogVisible(false); |
| this.#itemIsFocused = false; |
| } |
| |
| async #render(): Promise<void> { |
| if (!ComponentHelpers.ScheduledRender.isScheduledRender(this)) { |
| throw new Error('Menu render was not scheduled'); |
| } |
| // clang-format off |
| Lit.render(html` |
| <style>${menuStyles}</style> |
| <devtools-dialog |
| @clickoutsidedialog=${this.#closeDialog} |
| @forceddialogclose=${this.#closeDialog} |
| .position=${this.position} |
| .origin=${this.origin} |
| .dialogShownCallback=${this.#dialogDeployed.bind(this)} |
| .horizontalAlignment=${this.horizontalAlignment} |
| .getConnectorCustomXPosition=${this.getConnectorCustomXPosition} |
| ${ref(el => { |
| if (el instanceof HTMLElement) { |
| this.#dialog = el as Dialogs.Dialog.Dialog; |
| } |
| })} |
| > |
| <span id="container" role="menu" tabIndex="0" @keydown=${this.#handleDialogKeyDown} jslog=${VisualLogging.menu().track({resize: true, keydown: 'Escape'})}> |
| <slot @click=${this.#handleItemClick}> |
| </slot> |
| </span> |
| </devtools-dialog> |
| `, this.#shadow, { host: this }); |
| // clang-format on |
| } |
| } |
| |
| interface MenuItemData { |
| /** |
| * If true, selecting the item (by clicking it or pressing enter when its focused) |
| * would not cause the menu to be closed. |
| * Defaults to false. |
| */ |
| preventMenuCloseOnSelection: boolean; |
| /** |
| * The value associated with this item. It is used to determine what item is selected |
| * and is the data sent in a MenuItemSelectedEvent, when an item is selected. |
| */ |
| value: MenuItemValue; |
| /** |
| * Whether the item is selected. |
| */ |
| selected: boolean; |
| /** |
| * Whether the item is disabled. |
| */ |
| disabled: boolean; |
| } |
| |
| export class MenuItem extends HTMLElement { |
| readonly #shadow = this.attachShadow({mode: 'open'}); |
| connectedCallback(): void { |
| this.tabIndex = 0; |
| this.setAttribute('role', 'option'); |
| this.setAttribute('aria-selected', String(this.#props.selected)); |
| } |
| #props: MenuItemData = { |
| value: '', |
| preventMenuCloseOnSelection: false, |
| selected: false, |
| disabled: false, |
| }; |
| |
| get preventMenuCloseOnSelection(): boolean { |
| return this.#props.preventMenuCloseOnSelection; |
| } |
| |
| set preventMenuCloseOnSelection(preventMenuCloseOnSelection: boolean) { |
| this.#props.preventMenuCloseOnSelection = preventMenuCloseOnSelection; |
| void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); |
| } |
| |
| get value(): MenuItemValue { |
| return this.#props.value; |
| } |
| |
| set value(value: MenuItemValue) { |
| this.#props.value = value; |
| void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); |
| } |
| |
| get selected(): boolean { |
| return this.#props.selected; |
| } |
| |
| set selected(selected: boolean) { |
| this.#props.selected = selected; |
| this.setAttribute('aria-selected', String(selected)); |
| 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); |
| } |
| |
| async #render(): Promise<void> { |
| if (!ComponentHelpers.ScheduledRender.isScheduledRender(this)) { |
| throw new Error('MenuItem render was not scheduled'); |
| } |
| // clang-format off |
| |
| Lit.render(html` |
| <style>${menuItemStyles}</style> |
| <span class=${Lit.Directives.classMap({ |
| 'menu-item': true, |
| 'is-selected-item': this.selected, |
| 'is-disabled-item': this.disabled, |
| 'prevents-close': this.preventMenuCloseOnSelection, |
| })} |
| > |
| <slot></slot> |
| </span> |
| `, this.#shadow, { host: this }); |
| // clang-format on |
| } |
| } |
| |
| interface MenuGroupData { |
| name: string|null; |
| } |
| |
| export class MenuGroup extends HTMLElement { |
| readonly #shadow = this.attachShadow({mode: 'open'}); |
| |
| #props: MenuGroupData = { |
| name: null, |
| }; |
| |
| get name(): string|null { |
| return this.#props.name; |
| } |
| |
| set name(name: string|null) { |
| this.#props.name = name; |
| void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); |
| } |
| |
| async #render(): Promise<void> { |
| if (!ComponentHelpers.ScheduledRender.isScheduledRender(this)) { |
| throw new Error('MenuGroup render was not scheduled'); |
| } |
| // clang-format off |
| Lit.render(html` |
| <style>${menuGroupStyles}</style> |
| <span class="menu-group"> |
| <span class="menu-group-label">${this.name}</span> |
| <slot></slot> |
| </span> |
| `, this.#shadow, { host: this }); |
| // clang-format on |
| } |
| } |
| |
| customElements.define('devtools-menu', Menu); |
| customElements.define('devtools-menu-item', MenuItem); |
| customElements.define('devtools-menu-group', MenuGroup); |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'devtools-menu': Menu; |
| 'devtools-menu-item': MenuItem; |
| 'devtools-menu-group': MenuGroup; |
| } |
| |
| interface HTMLElementEventMap { |
| [MenuItemSelectedEvent.eventName]: MenuItemSelectedEvent; |
| [MenuCloseRequest.eventName]: MenuCloseRequest; |
| } |
| } |
| |
| export class MenuItemSelectedEvent extends Event { |
| static readonly eventName = 'menuitemselected'; |
| |
| constructor(public itemValue: MenuItemValue) { |
| super(MenuItemSelectedEvent.eventName, {bubbles: true, composed: true}); |
| } |
| } |
| export class MenuCloseRequest extends Event { |
| static readonly eventName = 'menucloserequest'; |
| constructor() { |
| super(MenuCloseRequest.eventName, {bubbles: true, composed: true}); |
| } |
| } |
| |
| export type MenuItemValue = string|number|boolean; |