Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/framework/mr-upload/mr-upload.js b/static_src/elements/framework/mr-upload/mr-upload.js
new file mode 100644
index 0000000..5fee672
--- /dev/null
+++ b/static_src/elements/framework/mr-upload/mr-upload.js
@@ -0,0 +1,322 @@
+// Copyright 2019 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.
+
+import {LitElement, html, css} from 'lit-element';
+
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-upload>`
+ *
+ * A file uploading widget for use in adding attachments and similar things.
+ *
+ */
+export class MrUpload extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: block;
+          width: 100%;
+          padding: 0.25em 4px;
+          border: 1px dashed var(--chops-gray-300);
+          box-sizing: border-box;
+          border-radius: 8px;
+          transition: background 0.2s ease-in-out,
+            border-color 0.2s ease-in-out;
+        }
+        :host([hidden]) {
+          display: none;
+        }
+        :host([expanded]) {
+          /* Expand the drag and drop area when a file is being dragged. */
+          min-height: 120px;
+        }
+        :host([highlighted]) {
+          border-color: var(--chops-primary-accent-color);
+          background: var(--chops-active-choice-bg);
+        }
+        input[type="file"] {
+          /* We need the file uploader to be hidden but still accessible. */
+          opacity: 0;
+          width: 0;
+          height: 0;
+          position: absolute;
+          top: -9999;
+          left: -9999;
+        }
+        input[type="file"]:focus + label {
+          /* TODO(zhangtiff): Find a way to either mimic native browser focus
+           * styles or make focus styles more consistent. */
+          box-shadow: 0 0 3px 1px hsl(193, 82%, 63%);
+        }
+        label.button {
+          margin-right: 8px;
+          padding: 0.1em 4px;
+          display: inline-flex;
+          width: auto;
+          cursor: pointer;
+          border: var(--chops-normal-border);
+          margin-left: 0;
+        }
+        label.button i.material-icons {
+          font-size: var(--chops-icon-font-size);
+        }
+        ul {
+          display: flex;
+          align-items: flex-start;
+          justify-content: flex-start;
+          flex-direction: column;
+        }
+        ul[hidden] {
+          display: none;
+        }
+        li {
+          display: inline-flex;
+          align-items: center;
+        }
+        li i.material-icons {
+          font-size: 14px;
+          margin: 0;
+        }
+        /* TODO(zhangtiff): Create a shared Material icon button component. */
+        button {
+          border-radius: 50%;
+          cursor: pointer;
+          background: 0;
+          border: 0;
+          padding: 0.25em;
+          margin-left: 4px;
+          display: inline-flex;
+          align-items: center;
+          justify-content: center;
+          transition: background 0.2s ease-in-out;
+        }
+        button:hover {
+          background: var(--chops-gray-200);
+        }
+        .controls {
+          display: flex;
+          flex-direction: row;
+          align-items: center;
+          justify-content: flex-start;
+          width: 100%;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <div class="controls">
+        <input id="file-uploader" type="file" multiple @change=${this._filesChanged}>
+        <label class="button" for="file-uploader">
+          <i class="material-icons" role="presentation">attach_file</i>Add attachments
+        </label>
+        Drop files here to add them (Max: 10.0 MB per comment)
+      </div>
+      <ul ?hidden=${!this.files || !this.files.length}>
+        ${this.files.map((file, i) => html`
+          <li>
+            ${file.name}
+            <button data-index=${i} @click=${this._removeFile}>
+              <i class="material-icons">clear</i>
+            </button>
+          </li>
+        `)}
+      </ul>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      files: {type: Array},
+      highlighted: {
+        type: Boolean,
+        reflect: true,
+      },
+      expanded: {
+        type: Boolean,
+        reflect: true,
+      },
+      _boundOnDragIntoWindow: {type: Object},
+      _boundOnDragOutOfWindow: {type: Object},
+      _boundOnDragInto: {type: Object},
+      _boundOnDragLeave: {type: Object},
+      _boundOnDrop: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.expanded = false;
+    this.highlighted = false;
+    this.files = [];
+    this._boundOnDragIntoWindow = this._onDragIntoWindow.bind(this);
+    this._boundOnDragOutOfWindow = this._onDragOutOfWindow.bind(this);
+    this._boundOnDragInto = this._onDragInto.bind(this);
+    this._boundOnDragLeave = this._onDragLeave.bind(this);
+    this._boundOnDrop = this._onDrop.bind(this);
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    this.addEventListener('dragenter', this._boundOnDragInto);
+    this.addEventListener('dragover', this._boundOnDragInto);
+
+    this.addEventListener('dragleave', this._boundOnDragLeave);
+    this.addEventListener('drop', this._boundOnDrop);
+
+    window.addEventListener('dragenter', this._boundOnDragIntoWindow);
+    window.addEventListener('dragover', this._boundOnDragIntoWindow);
+    window.addEventListener('dragleave', this._boundOnDragOutOfWindow);
+    window.addEventListener('drop', this._boundOnDragOutOfWindow);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    window.removeEventListener('dragenter', this._boundOnDragIntoWindow);
+    window.removeEventListener('dragover', this._boundOnDragIntoWindow);
+    window.removeEventListener('dragleave', this._boundOnDragOutOfWindow);
+    window.removeEventListener('drop', this._boundOnDragOutOfWindow);
+  }
+
+  reset() {
+    this.files = [];
+  }
+
+  get hasAttachments() {
+    return this.files.length !== 0;
+  }
+
+  async loadFiles() {
+    // TODO(zhangtiff): Add preloading of files on change.
+    if (!this.files || !this.files.length) return [];
+    const loads = this.files.map(this._loadLocalFile);
+    return await Promise.all(loads);
+  }
+
+  _onDragInto(e) {
+    // Combined event handler for dragenter and dragover.
+    if (!this._eventGetFiles(e).length) return;
+    e.preventDefault();
+    this.highlighted = true;
+  }
+
+  _onDragLeave(e) {
+    // Unhighlight the drop area when the user undrops the component.
+    if (!this._eventGetFiles(e).length) return;
+    e.preventDefault();
+    this.highlighted = false;
+  }
+
+  _onDrop(e) {
+    // Add the files the user is dragging when dragging into the component.
+    const files = this._eventGetFiles(e);
+    if (!files.length) return;
+    e.preventDefault();
+    this.highlighted = false;
+    this._addFiles(files);
+  }
+
+  _onDragIntoWindow(e) {
+    // Expand the drop area when any file is being dragged in the window.
+    if (!this._eventGetFiles(e).length) return;
+    e.preventDefault();
+    this.expanded = true;
+  }
+
+  _onDragOutOfWindow(e) {
+    // Unexpand the component when a file is no longer being dragged.
+    if (!this._eventGetFiles(e).length) return;
+    e.preventDefault();
+    this.expanded = false;
+  }
+
+  _eventGetFiles(e) {
+    if (!e || !e.dataTransfer) return [];
+    const dt = e.dataTransfer;
+
+    if (dt.items && dt.items.length) {
+      const filteredItems = [...dt.items].filter(
+          (item) => item.kind === 'file');
+      return filteredItems.map((item) => item.getAsFile());
+    }
+
+    return [...dt.files];
+  }
+
+  _loadLocalFile(f) {
+    // The FileReader API only accepts callbacks for asynchronous handling,
+    // so it's easier to use Promises here. But by wrapping this logic
+    // in a Promise, we can use async/await in outer code.
+    return new Promise((resolve, reject) => {
+      const r = new FileReader();
+      r.onloadend = () => {
+        resolve({filename: f.name, content: btoa(r.result)});
+      };
+      r.onerror = () => {
+        reject(r.error);
+      };
+
+      r.readAsBinaryString(f);
+    });
+  }
+
+  /**
+   * @param {Event} e
+   * @fires CustomEvent#change
+   * @private
+   */
+  _filesChanged(e) {
+    const input = e.currentTarget;
+    if (!input.files) return;
+    this._addFiles(input.files);
+    this.dispatchEvent(new CustomEvent('change'));
+  }
+
+  _addFiles(newFiles) {
+    if (!newFiles) return;
+    // Spread files to convert it from a FileList to an Array.
+    const files = [...newFiles].filter((f1) => {
+      const matchingFile = this.files.some((f2) => this._filesMatch(f1, f2));
+      return !matchingFile;
+    });
+
+    this.files = this.files.concat(files);
+  }
+
+  _filesMatch(a, b) {
+    // NOTE: This function could return a false positive if two files have the
+    // exact same name, lastModified time, size, and type but different
+    // content. This is extremely unlikely, however.
+    return a.name === b.name && a.lastModified === b.lastModified &&
+      a.size === b.size && a.type === b.type;
+  }
+
+  _removeFile(e) {
+    const target = e.currentTarget;
+
+    // This should always be an int.
+    const index = Number.parseInt(target.dataset.index);
+    if (index < 0 || index >= this.files.length) return;
+
+    this.files.splice(index, 1);
+
+    // Trigger an update.
+    this.files = [...this.files];
+  }
+}
+customElements.define('mr-upload', MrUpload);