blob: 389b53ca854870242ff7365c9c7ecec68a988847 [file] [log] [blame]
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../styles/shared-styles';
import '../gr-button/gr-button';
import '../gr-date-formatter/gr-date-formatter';
import '../gr-file-status/gr-file-status';
import '../gr-vote-chip/gr-vote-chip';
import {css, html, LitElement, nothing, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {ApprovalInfo, LabelInfo} from '../../../api/rest-api';
import {CommentThread, Timestamp} from '../../../types/common';
import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
import {GrButton} from '../gr-button/gr-button';
import {assertIsDefined} from '../../../utils/common-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {ValueChangedEvent} from '../../../types/events';
import {incrementalRepeat} from '../../lit/incremental-repeat';
import {when} from 'lit/directives/when.js';
import {computeTruncatedPath, isMagicPath} from '../../../utils/path-list-util';
import {fireNoBubble} from '../../../utils/event-util';
import {classMap} from 'lit/directives/class-map.js';
import '@material/web/divider/divider';
import '@material/web/menu/menu';
import '@material/web/menu/menu-item';
import {MdMenu} from '@material/web/menu/menu';
import {isSafari, Key} from '../../../utils/dom-util';
import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
/**
* Required values are text and value. mobileText and triggerText will
* fall back to text if not provided.
*
* If bottomText is not provided, nothing will display on the second
* line.
*
* If date is not provided, nothing will be displayed in its place.
*/
export interface DropdownItem {
text: string;
value: string | number;
bottomText?: string;
triggerText?: string;
mobileText?: string;
date?: Timestamp;
disabled?: boolean;
file?: NormalizedFileInfo;
commentThreads?: CommentThread[];
deemphasizeReason?: string;
vote?: ApprovalInfo;
label?: LabelInfo;
}
declare global {
interface HTMLElementEventMap {
'value-change': ValueChangedEvent<string>;
}
}
@customElement('gr-dropdown-list')
export class GrDropdownList extends LitElement {
@query('#dropdown')
dropdown?: MdMenu;
@query('#trigger')
trigger?: GrButton;
/**
* Fired when the selected value changes
*
* @event value-change
*
* @property {string} value
*/
@property({type: Number})
initialCount = 75;
@property({type: Array})
items?: DropdownItem[];
@property({type: String})
text?: string;
@property({type: Boolean})
disabled = false;
@property({type: String})
value = '';
@property({type: Boolean, attribute: 'show-copy-for-trigger-text'})
showCopyForTriggerText = false;
@state()
selectedIndex = 0;
@state()
private opened = false;
@state() private hadKeyboardEvent = false;
cursor = new GrCursorManager();
constructor() {
super();
this.cursor.cursorTargetAttribute = 'selected';
this.cursor.focusOnMove = true;
}
static override get styles() {
return [
sharedStyles,
css`
:host {
display: inline-block;
}
#triggerText {
-moz-user-select: text;
-ms-user-select: text;
-webkit-user-select: text;
user-select: text;
}
.dropdown-trigger {
cursor: pointer;
padding: 0;
}
md-menu {
white-space: nowrap;
--md-menu-container-color: var(--dropdown-background-color);
--md-menu-top-space: 0px;
--md-menu-bottom-space: 0px;
--md-focus-ring-duration: 0s;
max-height: calc(100vh - 48px);
}
md-divider {
margin: auto;
--md-divider-color: var(--border-color);
}
md-menu-item {
max-height: 70vh;
min-width: 266px;
--md-sys-color-on-surface: var(
--gr-dropdown-item-color,
var(--primary-text-color, black)
);
--md-sys-color-on-secondary-container: var(
--gr-dropdown-item-color,
var(--primary-text-color, black)
);
--md-sys-typescale-body-large-font: inherit;
--md-menu-item-hover-state-layer-color: var(
--selection-background-color
);
--md-menu-item-hover-state-layer-opacity: 1;
--md-menu-item-selected-container-color: var(
--selection-background-color
);
--md-focus-ring-color: var(--gr-dropdown-focus-ring-color);
--md-menu-item-one-line-container-height: auto;
}
md-menu-item[active] .topContent {
font-weight: bold;
}
.dropdown {
position: relative;
}
.dropdown-menu {
position: relative;
z-index: 120;
}
.bottomContent {
color: var(--deemphasized-text-color);
white-space: pre-wrap;
}
.bottomContent,
.topContent {
display: flex;
justify-content: space-between;
flex-direction: row;
width: 100%;
}
gr-button {
font-family: var(--trigger-style-font-family);
--gr-button-text-color: var(--trigger-style-text-color);
}
gr-vote-chip {
margin-right: var(--spacing-s);
}
gr-date-formatter {
color: var(--deemphasized-text-color);
margin-left: var(--spacing-xxl);
white-space: nowrap;
}
.topContent.deemphasized {
color: var(--deemphasized-text-color);
font-style: italic;
}
gr-comments-summary {
padding-left: var(--spacing-s);
}
.copyClipboard {
display: inline-flex;
vertical-align: top;
}
.mobileText {
display: none;
}
.desktopText {
display: inline-block;
}
gr-file-status {
margin-left: var(--spacing-xxl);
}
@media only screen and (max-width: 50em) {
.mobileText {
display: inline-block;
}
.desktopText {
display: none;
}
}
`,
];
}
protected override willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has('value') || changedProperties.has('items')) {
this.updateText();
}
if (changedProperties.has('value')) {
fireNoBubble(this, 'value-change', {value: this.value});
}
}
override updated(changedProperties: PropertyValues) {
if (changedProperties.has('items')) {
this.resetCursorStops();
}
if (changedProperties.has('opened')) {
if (this.opened) {
this.resetCursorStops();
this.cursor.setCursorAtIndex(this.selectedIndex);
if (this.cursor.target !== null) {
this.cursor.target.focus();
if (isSafari() && !this.hadKeyboardEvent) {
const mdFocusRing = this.cursor.target?.shadowRoot
?.querySelector('md-item')
?.querySelector('md-focus-ring');
if (mdFocusRing) mdFocusRing.visible = false;
}
}
this.setUpGlobalEventListeners();
} else {
this.cleanUpGlobalEventListeners();
}
}
}
private setUpGlobalEventListeners() {
const passiveOptions: AddEventListenerOptions = {passive: true};
window.addEventListener('resize', this.onWindowResize, passiveOptions);
window.addEventListener('scroll', this.onWindowResize, passiveOptions);
}
private cleanUpGlobalEventListeners() {
const passiveOptions: AddEventListenerOptions = {passive: true};
window.removeEventListener('resize', this.onWindowResize, passiveOptions);
window.removeEventListener('scroll', this.onWindowResize, passiveOptions);
}
private readonly onWindowResize = () => {
this.dropdown?.reposition();
};
override render() {
return html`<div class="dropdown">
<gr-button
id="trigger"
?disabled=${!!this.disabled}
down-arrow
link
class="dropdown-trigger"
slot="dropdown-trigger"
@click=${this.showDropdownTapHandler}
@keydown=${(e: KeyboardEvent) => {
this.hadKeyboardEvent = true;
if (
(e.key === Key.DOWN || e.key === Key.UP) &&
!this.dropdown?.open
) {
e.preventDefault();
e.stopPropagation();
this.dropdown?.show();
}
}}
@mousedown=${() => {
this.hadKeyboardEvent = false;
}}
@pointerdown=${() => {
this.hadKeyboardEvent = false;
}}
@touchstart=${() => {
this.hadKeyboardEvent = false;
}}
>
<span id="triggerText" class="desktopText">${this.text}</span>
<span id="triggerText" class="mobileText"
>${computeTruncatedPath(this.text)}</span
>
<gr-copy-clipboard
class="copyClipboard"
?hidden=${!this.showCopyForTriggerText}
hideInput
.text=${this.text}
></gr-copy-clipboard>
</gr-button>
<div class="dropdown-menu">
<md-menu
id="dropdown"
anchor="trigger"
default-focus="none"
tabindex="-1"
.menuCorner=${'start-start'}
?quick=${true}
.skipRestoreFocus=${true}
positioning="fixed"
@click=${this.handleDropdownClick}
@opened=${(e: Event) => {
this.opened = true;
this.scrollToSelected(e);
}}
@closed=${() => {
this.opened = false;
this.hadKeyboardEvent = false;
// This is an ugly hack but works.
this.cursor.target?.removeAttribute('selected');
this.cursor.target?.blur();
}}
>
${incrementalRepeat({
values: this.items ?? [],
initialCount: this.initialCount,
mapFn: (item, index) =>
this.renderMdMenuItem(item as DropdownItem, index),
})}
</md-menu>
</div>
</div> `;
}
private renderMdMenuItem(item: DropdownItem, index: number) {
if (this.value === String(item.value)) {
this.selectedIndex = index;
}
return html`
<md-menu-item
?selected=${this.value === String(item.value)}
?active=${this.value === String(item.value)}
?disabled=${!!item.disabled}
@click=${() => {
this.value = String(item.value);
}}
@keydown=${(e: KeyboardEvent) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.preventDefault();
e.stopPropagation();
this.handleEnter();
}
if (e.key === Key.UP) {
e.preventDefault();
e.stopPropagation();
this.handleUp();
}
if (e.key === Key.DOWN) {
e.preventDefault();
e.stopPropagation();
this.handleDown();
}
}}
>
<div
class=${classMap({
topContent: true,
deemphasized: !!item.deemphasizeReason,
})}
>
<div>
<span class="desktopText">${item.text}</span>
<span class="mobileText">${this.computeMobileText(item)}</span>
${when(
!!item.deemphasizeReason,
() => html`<span>| ${item.deemphasizeReason}</span>`
)}
${when(
item.commentThreads,
() => html`<gr-comments-summary
.commentThreads=${item.commentThreads}
emptyWhenNoComments
showAvatarForResolved
></gr-comments-summary>`
)}
</div>
${when(
item.vote,
() =>
html` <div>
<gr-vote-chip
.vote=${item.vote}
.label=${item.label}
></gr-vote-chip>
</div>`
)}
${when(
item.date,
() => html`
<gr-date-formatter .dateStr=${item.date}></gr-date-formatter>
`
)}
${when(
item.file?.status && !isMagicPath(item.file?.__path),
() => html`
<gr-file-status .status=${item.file?.status}></gr-file-status>
`
)}
</div>
${when(
item.bottomText,
() => html`
<div class="bottomContent">
<div>${item.bottomText}</div>
</div>
`
)}
</md-menu-item>
${index < this.items!.length - 1
? html`<md-divider role="separator" tabindex="-1"></md-divider>`
: nothing}
`;
}
/**
* Handle the up key.
*/
private handleUp() {
this.cursor.previous();
}
/**
* Handle the down key.
*/
private handleDown() {
this.cursor.next();
}
/**
* Handle the enter key.
*/
private handleEnter() {
if (this.cursor.target !== null) {
const el = this.cursor.target.shadowRoot?.querySelector(':not([hidden])');
if (el) {
(el as HTMLElement).click();
}
}
}
/**
* Handle a click on the md-menu element.
*/
private handleDropdownClick(e?: MouseEvent) {
assertIsDefined(this.dropdown);
if (e) {
e.preventDefault();
e.stopPropagation();
}
this.dropdown.close();
// For some reason this is needed, otherwise a console warning is thrown,
// with something about aria-hidden can't be set because md-menu is focused already.
e && e.currentTarget && (e.currentTarget as HTMLElement).blur();
}
private updateText() {
if (this.value === undefined || this.items === undefined) {
return;
}
const selectedObj = this.items.find(item => `${item.value}` === this.value);
if (!selectedObj) {
return;
}
this.text = selectedObj.triggerText
? selectedObj.triggerText
: selectedObj.text;
}
/**
* Handle a click on the button to open the dropdown.
*/
private showDropdownTapHandler(e: Event) {
e.preventDefault();
e.stopPropagation();
this.open();
}
/**
* Open the dropdown.
*/
open() {
assertIsDefined(this.dropdown);
this.dropdown.open = !this.dropdown.open;
this.dropdown.focus();
}
// Private but used in tests.
computeMobileText(item: DropdownItem) {
return item.mobileText ? item.mobileText : item.text;
}
private scrollToSelected(e: Event) {
const target = e.target as HTMLElement;
const selected = target.querySelector<MdMenu>('md-menu-item[selected]');
selected?.scrollIntoView({block: 'nearest'});
}
/**
* Recompute the stops for the dropdown item cursor.
*/
private resetCursorStops() {
assertIsDefined(this.dropdown);
if (this.items && this.items.length > 0 && this.dropdown.open) {
this.cursor.stops = Array.from(
this.shadowRoot?.querySelectorAll('md-menu-item') ?? []
);
}
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-dropdown-list': GrDropdownList;
}
}