| /** |
| * @license |
| * Copyright 2016 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '@polymer/iron-dropdown/iron-dropdown'; |
| import '../gr-button/gr-button'; |
| import {GrButton} from '../gr-button/gr-button'; |
| import '../gr-cursor-manager/gr-cursor-manager'; |
| import '../gr-tooltip-content/gr-tooltip-content'; |
| import '../../../styles/shared-styles'; |
| import {getBaseUrl} from '../../../utils/url-util'; |
| import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown'; |
| import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager'; |
| import {property, customElement, query, state} from 'lit/decorators.js'; |
| import {Key} from '../../../utils/dom-util'; |
| import {css, html, LitElement, nothing, PropertyValues} from 'lit'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {ifDefined} from 'lit/directives/if-defined.js'; |
| import {fire} from '../../../utils/event-util'; |
| import {ValueChangedEvent} from '../../../types/events'; |
| import {assertIsDefined} from '../../../utils/common-util'; |
| import {ShortcutController} from '../../lit/shortcut-controller'; |
| |
| const REL_NOOPENER = 'noopener'; |
| const REL_EXTERNAL = 'external'; |
| |
| declare global { |
| interface HTMLElementEventMap { |
| 'opened-changed': ValueChangedEvent<boolean>; |
| } |
| interface HTMLElementTagNameMap { |
| 'gr-dropdown': GrDropdown; |
| } |
| } |
| |
| export interface DropdownLink { |
| url?: string; |
| name?: string; |
| external?: boolean; |
| target?: string | null; |
| download?: boolean; |
| id?: string; |
| tooltip?: string; |
| } |
| |
| export interface DropdownContent { |
| text: string; |
| bold?: boolean; |
| } |
| |
| @customElement('gr-dropdown') |
| export class GrDropdown extends LitElement { |
| @query('#dropdown') |
| dropdown?: IronDropdownElement; |
| |
| @query('#trigger') |
| trigger?: GrButton; |
| |
| static override get styles() { |
| return [ |
| sharedStyles, |
| css` |
| :host { |
| display: inline-block; |
| } |
| .dropdown-trigger { |
| text-decoration: none; |
| width: 100%; |
| } |
| .dropdown-content { |
| background-color: var(--dropdown-background-color); |
| box-shadow: var(--elevation-level-2); |
| min-width: 112px; |
| max-width: 280px; |
| } |
| gr-button { |
| vertical-align: top; |
| } |
| gr-avatar { |
| height: 2em; |
| width: 2em; |
| vertical-align: middle; |
| } |
| gr-button[link]:focus { |
| outline: 5px auto -webkit-focus-ring-color; |
| } |
| ul { |
| list-style: none; |
| } |
| .topContent, |
| li { |
| border-bottom: 1px solid var(--border-color); |
| } |
| li:last-of-type { |
| border: none; |
| } |
| li .itemAction { |
| cursor: pointer; |
| display: block; |
| padding: var(--spacing-m) var(--spacing-l); |
| } |
| li .itemAction { |
| color: var(--gr-dropdown-item-color); |
| background-color: var(--gr-dropdown-item-background-color); |
| border: var(--gr-dropdown-item-border); |
| text-transform: var(--gr-dropdown-item-text-transform); |
| } |
| li .itemAction.disabled { |
| color: var(--deemphasized-text-color); |
| cursor: default; |
| } |
| li .itemAction:link, |
| li .itemAction:visited { |
| text-decoration: none; |
| } |
| li .itemAction:not(.disabled):hover { |
| background-color: var(--hover-background-color); |
| } |
| li:focus, |
| li.selected { |
| background-color: var(--selection-background-color); |
| outline: none; |
| } |
| li:focus .itemAction, |
| li.selected .itemAction { |
| background-color: transparent; |
| } |
| .topContent { |
| display: block; |
| padding: var(--spacing-m) var(--spacing-l); |
| color: var(--gr-dropdown-item-color); |
| background-color: var(--gr-dropdown-item-background-color); |
| border: var(--gr-dropdown-item-border); |
| text-transform: var(--gr-dropdown-item-text-transform); |
| } |
| .bold-text { |
| font-weight: var(--font-weight-bold); |
| } |
| `, |
| ]; |
| } |
| /** |
| * Fired when a non-link dropdown item with the given ID is tapped. |
| * |
| * @event tap-item-<id> |
| */ |
| |
| /** |
| * Fired when a non-link dropdown item is tapped. |
| * |
| * @event tap-item |
| */ |
| |
| @property({type: Array}) |
| items?: DropdownLink[]; |
| |
| @property({type: Boolean, attribute: 'down-arrow'}) |
| downArrow = false; |
| |
| @property({type: Array}) |
| topContent?: DropdownContent[]; |
| |
| @property({type: String, attribute: 'horizontal-align'}) |
| horizontalAlign = 'left'; |
| |
| /** |
| * Style the dropdown trigger as a link (rather than a button). |
| */ |
| |
| @property({type: Boolean}) |
| link = false; |
| |
| @property({type: Number, attribute: 'vertical-offset'}) |
| verticalOffset = 40; |
| |
| @state() |
| private opened = false; |
| |
| /** |
| * List the IDs of dropdown buttons to be disabled. (Note this only |
| * disables buttons and not link entries.) |
| */ |
| @property({type: Array}) |
| disabledIds: string[] = []; |
| |
| // Used within the tests so needs to be non-private. |
| cursor = new GrCursorManager(); |
| |
| private readonly shortcuts = new ShortcutController(this); |
| |
| constructor() { |
| super(); |
| this.cursor.cursorTargetClass = 'selected'; |
| this.cursor.focusOnMove = true; |
| this.shortcuts.addLocal({key: Key.UP}, () => this.handleUp()); |
| this.shortcuts.addLocal({key: Key.DOWN}, () => this.handleDown()); |
| this.shortcuts.addLocal({key: Key.ENTER}, () => this.handleEnter()); |
| this.shortcuts.addLocal({key: Key.SPACE}, () => this.handleEnter()); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| } |
| |
| override disconnectedCallback() { |
| this.cursor.unsetCursor(); |
| super.disconnectedCallback(); |
| } |
| |
| override willUpdate(changedProperties: PropertyValues) { |
| if (changedProperties.has('opened')) { |
| fire(this, 'opened-changed', {value: this.opened}); |
| } |
| } |
| |
| override updated(changedProperties: PropertyValues) { |
| if (changedProperties.has('items')) { |
| this.resetCursorStops(); |
| } |
| if (changedProperties.has('opened') && this.opened) { |
| this.resetCursorStops(); |
| this.cursor.setCursorAtIndex(0); |
| if (this.cursor.target !== null) this.cursor.target.focus(); |
| } |
| } |
| |
| override render() { |
| return html` <gr-button |
| ?link=${this.link} |
| class="dropdown-trigger" |
| id="trigger" |
| ?down-arrow=${this.downArrow} |
| @click=${this.dropdownTriggerTapHandler} |
| > |
| <slot></slot> |
| </gr-button> |
| <iron-dropdown |
| id="dropdown" |
| .verticalAlign=${'top'} |
| .verticalOffset=${this.verticalOffset} |
| allowOutsideScroll |
| .horizontalAlign=${this.horizontalAlign} |
| @click=${() => this.close()} |
| @opened-changed=${(e: CustomEvent) => (this.opened = e.detail.value)} |
| > |
| ${this.renderDropdownContent()} |
| </iron-dropdown>`; |
| } |
| |
| private renderDropdownContent() { |
| return html` <div class="dropdown-content" slot="dropdown-content"> |
| <ul> |
| ${this.renderTopContent()} |
| ${(this.items ?? []).map(link => this.renderDropdownLink(link))} |
| </ul> |
| </div>`; |
| } |
| |
| private renderTopContent() { |
| if (!this.topContent) return nothing; |
| return html` |
| <div class="topContent"> |
| ${(this.topContent ?? []).map(item => this.renderTopContentItem(item))} |
| </div> |
| `; |
| } |
| |
| private renderTopContentItem(item: DropdownContent) { |
| return html` |
| <div class="${this.getClassIfBold(item.bold)} top-item" tabindex="-1"> |
| ${item.text} |
| </div> |
| `; |
| } |
| |
| private renderDropdownLink(link: DropdownLink) { |
| const disabledClass = this.computeDisabledClass(link.id); |
| return html` |
| <li tabindex="-1"> |
| <gr-tooltip-content |
| ?has-tooltip=${!!link.tooltip} |
| title=${ifDefined(link.tooltip)} |
| > |
| <span |
| class="itemAction ${disabledClass}" |
| data-id=${ifDefined(link.id)} |
| @click=${this.handleItemTap} |
| ?hidden=${!!link.url} |
| tabindex="-1" |
| >${link.name}</span |
| > |
| <a |
| class="itemAction" |
| href=${this.computeLinkURL(link)} |
| ?download=${!!link.download} |
| rel=${ifDefined(this.computeLinkRel(link) ?? undefined)} |
| target=${ifDefined(link.target ?? undefined)} |
| ?hidden=${!link.url} |
| tabindex="-1" |
| >${link.name}</a |
| > |
| </gr-tooltip-content> |
| </li> |
| `; |
| } |
| |
| /** |
| * Handle the up key. |
| */ |
| private handleUp() { |
| assertIsDefined(this.dropdown); |
| if (this.dropdown.opened) { |
| this.cursor.previous(); |
| } else { |
| this.open(); |
| } |
| } |
| |
| /** |
| * Handle the down key. |
| */ |
| private handleDown() { |
| assertIsDefined(this.dropdown); |
| if (this.dropdown.opened) { |
| this.cursor.next(); |
| } else { |
| this.open(); |
| } |
| } |
| |
| /** |
| * Handle the enter key. |
| */ |
| private handleEnter() { |
| assertIsDefined(this.dropdown); |
| if (this.dropdown.opened) { |
| // TODO(milutin): This solution is not particularly robust in general. |
| // Since gr-tooltip-content click on shadow dom is not propagated down, |
| // we have to target `a` inside it. |
| if (this.cursor.target !== null) { |
| const el = this.cursor.target.querySelector(':not([hidden]) a'); |
| if (el) { |
| (el as HTMLElement).click(); |
| } |
| } |
| } else { |
| this.open(); |
| } |
| } |
| |
| /** |
| * Handle a click on the button to open the dropdown. |
| */ |
| private dropdownTriggerTapHandler(e: MouseEvent) { |
| assertIsDefined(this.dropdown); |
| e.preventDefault(); |
| e.stopPropagation(); |
| if (this.dropdown.opened) { |
| this.close(); |
| } else { |
| this.open(); |
| } |
| } |
| |
| /** |
| * Open the dropdown and initialize the cursor. |
| * Private but used in tests. |
| */ |
| open() { |
| assertIsDefined(this.dropdown); |
| this.dropdown.open(); |
| } |
| |
| // Private but used in tests. |
| close() { |
| // async is needed so that that the click event is fired before the |
| // dropdown closes (This was a bug for touch devices). |
| setTimeout(() => { |
| this.dropdown?.close(); |
| }, 1); |
| } |
| |
| /** |
| * Get the class for a top-content item based on the given boolean. |
| * |
| * @param bold Whether the item is bold. |
| * @return The class for the top-content item. |
| * |
| * Private but used in tests. |
| */ |
| getClassIfBold(bold?: boolean) { |
| return bold ? 'bold-text' : ''; |
| } |
| |
| /** |
| * Build a URL for the given host and path. The base URL will be only added, |
| * if it is not already included in the path. |
| * |
| * @return The scheme-relative URL. |
| */ |
| _computeURLHelper(host: string, path: string) { |
| const base = path.startsWith(getBaseUrl()) ? '' : getBaseUrl(); |
| return '//' + host + base + path; |
| } |
| |
| /** |
| * Build a scheme-relative URL for the current host. Will include the base |
| * URL if one is present. Note: the URL will be scheme-relative but absolute |
| * with regard to the host. |
| * |
| * @param path The path for the URL. |
| * @return The scheme-relative URL. |
| */ |
| private computeRelativeURL(path: string) { |
| const host = window.location.host; |
| return this._computeURLHelper(host, path); |
| } |
| |
| /** |
| * Compute the URL for a link object. |
| * |
| * Private but used in tests. |
| */ |
| computeLinkURL(link: DropdownLink) { |
| if (typeof link.url === 'undefined') { |
| return ''; |
| } |
| if (link.target || !link.url.startsWith('/')) { |
| return link.url; |
| } |
| return this.computeRelativeURL(link.url); |
| } |
| |
| /** |
| * Compute the value for the rel attribute of an anchor for the given link |
| * object. If the link has a target value, then the rel must be "noopener" |
| * for security reasons. |
| * Private but used in tests. |
| */ |
| computeLinkRel(link: DropdownLink) { |
| // Note: noopener takes precedence over external. |
| if (link.target) { |
| return REL_NOOPENER; |
| } |
| if (link.external) { |
| return REL_EXTERNAL; |
| } |
| return null; |
| } |
| |
| /** |
| * Handle a click on an item of the dropdown. |
| */ |
| private handleItemTap(e: MouseEvent) { |
| if (e.target === null || !this.items) { |
| return; |
| } |
| const id = (e.target as Element).getAttribute('data-id'); |
| const item = this.items.find(item => item.id === id); |
| if (id && !this.disabledIds.includes(id)) { |
| if (item) { |
| this.dispatchEvent( |
| new CustomEvent('tap-item', { |
| detail: item, |
| bubbles: true, |
| composed: true, |
| }) |
| ); |
| } |
| this.dispatchEvent(new CustomEvent('tap-item-' + id)); |
| } |
| } |
| |
| /** |
| * Recompute the stops for the dropdown item cursor. |
| */ |
| private resetCursorStops() { |
| assertIsDefined(this.dropdown); |
| if (this.items && this.items.length > 0 && this.dropdown?.opened) { |
| this.cursor.stops = Array.from( |
| this.shadowRoot?.querySelectorAll('li') ?? [] |
| ); |
| } |
| } |
| |
| /** |
| * If a dropdown item is shown as a button, get the class for the button. |
| * |
| * @return The class for the item button. |
| */ |
| private computeDisabledClass(id?: string) { |
| return id && this.disabledIds.includes(id) ? 'disabled' : ''; |
| } |
| } |