blob: 3a8946aa5a19b8821065a3699f70311440d1aa01 [file] [log] [blame]
/**
* @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' : '';
}
}