Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/chops/chops-announcement/chops-announcement.js b/static_src/elements/chops/chops-announcement/chops-announcement.js
new file mode 100644
index 0000000..477e7d2
--- /dev/null
+++ b/static_src/elements/chops/chops-announcement/chops-announcement.js
@@ -0,0 +1,181 @@
+// Copyright 2020 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';
+
+// URL where announcements are fetched from.
+const ANNOUNCEMENT_SERVICE =
+  'https://chopsdash.appspot.com/prpc/dashboard.ChopsAnnouncements/SearchAnnouncements';
+
+// Prefix prepended to responses for security reasons.
+export const XSSI_PREFIX = ')]}\'';
+
+const FETCH_HEADERS = Object.freeze({
+  'accept': 'application/json',
+  'content-type': 'application/json',
+});
+
+// How often to refresh announcements.
+export const REFRESH_TIME_MS = 5 * 60 * 1000;
+
+/**
+ * @typedef {Object} Announcement
+ * @property {string} id
+ * @property {string} messageContent
+ */
+
+/**
+ * @typedef {Object} AnnouncementResponse
+ * @property {Array<Announcement>} announcements
+ */
+
+/**
+ * `<chops-announcement>` displays a ChopsDash message when there's an outage
+ * or other important announcement.
+ *
+ * @customElement chops-announcement
+ */
+export class ChopsAnnouncement extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+        width: 100%;
+      }
+      p {
+        display: block;
+        color: #222;
+        font-size: 13px;
+        background: #FFCDD2; /* Material design red */
+        width: 100%;
+        text-align: center;
+        padding: 0.5em 16px;
+        box-sizing: border-box;
+        margin: 0;
+        /* Using a red-tinted grey border makes hues feel harmonious. */
+        border-bottom: 1px solid #D6B3B6;
+      }
+    `;
+  }
+  /** @override */
+  render() {
+    if (this._error) {
+      return html`<p><strong>Error: </strong>${this._error}</p>`;
+    }
+    return html`
+      ${this._announcements.map(
+      ({messageContent}) => html`<p>${messageContent}</p>`)}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      service: {type: String},
+      _error: {type: String},
+      _announcements: {type: Array},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** @type {string} */
+    this.service = undefined;
+    /** @type {string} */
+    this._error = undefined;
+    /** @type {Array<Announcement>} */
+    this._announcements = [];
+
+    /** @type {number} Interval ID returned by window.setInterval. */
+    this._interval = undefined;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('service')) {
+      if (this.service) {
+        this.startRefresh();
+      } else {
+        this.stopRefresh();
+      }
+    }
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    this.stopRefresh();
+  }
+
+  /**
+   * Set up autorefreshing logic or announcement information.
+   */
+  startRefresh() {
+    this.stopRefresh();
+    this.refresh();
+    this._interval = window.setInterval(() => this.refresh(), REFRESH_TIME_MS);
+  }
+
+  /**
+   * Logic for clearing refresh behavior.
+   */
+  stopRefresh() {
+    if (this._interval) {
+      window.clearInterval(this._interval);
+    }
+  }
+
+  /**
+   * Refresh the announcement banner.
+   */
+  async refresh() {
+    try {
+      const {announcements = []} = await this.fetch(this.service);
+      this._error = undefined;
+      this._announcements = announcements;
+    } catch (e) {
+      this._error = e.message;
+      this._announcements = [];
+    }
+  }
+
+  /**
+   * Fetches the announcement for a given service.
+   * @param {string} service Name of the service to fetch from ChopsDash.
+   *   ie: "monorail"
+   * @return {Promise<AnnouncementResponse>} ChopsDash response JSON.
+   * @throws {Error} If something went wrong while fetching.
+   */
+  async fetch(service) {
+    const message = {
+      retired: false,
+      platformName: service,
+    };
+
+    const response = await window.fetch(ANNOUNCEMENT_SERVICE, {
+      method: 'POST',
+      headers: FETCH_HEADERS,
+      body: JSON.stringify(message),
+    });
+
+    if (!response.ok) {
+      throw new Error('Something went wrong while fetching announcements');
+    }
+
+    // We can't use response.json() because of the XSSI prefix.
+    const text = await response.text();
+
+    if (!text.startsWith(XSSI_PREFIX)) {
+      throw new Error(`No XSSI prefix in announce response: ${XSSI_PREFIX}`);
+    }
+
+    return JSON.parse(text.substr(XSSI_PREFIX.length));
+  }
+}
+
+customElements.define('chops-announcement', ChopsAnnouncement);
diff --git a/static_src/elements/chops/chops-announcement/chops-announcement.test.js b/static_src/elements/chops/chops-announcement/chops-announcement.test.js
new file mode 100644
index 0000000..fa9643f
--- /dev/null
+++ b/static_src/elements/chops/chops-announcement/chops-announcement.test.js
@@ -0,0 +1,194 @@
+// Copyright 2020 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 {assert} from 'chai';
+import {ChopsAnnouncement, REFRESH_TIME_MS,
+  XSSI_PREFIX} from './chops-announcement.js';
+import sinon from 'sinon';
+
+let element;
+let clock;
+
+describe('chops-announcement', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-announcement');
+    document.body.appendChild(element);
+
+    clock = sinon.useFakeTimers({
+      now: new Date(0),
+      shouldAdvanceTime: false,
+    });
+
+    sinon.stub(window, 'fetch');
+  });
+
+  afterEach(() => {
+    if (document.body.contains(element)) {
+      document.body.removeChild(element);
+    }
+
+    clock.restore();
+
+    window.fetch.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsAnnouncement);
+  });
+
+  it('does not request announcements when no service specified', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = '';
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(element.fetch);
+  });
+
+  it('requests announcements when service is specified', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+  });
+
+  it('refreshes announcements regularly', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+
+    clock.tick(REFRESH_TIME_MS);
+
+    await element.updateComplete;
+
+    sinon.assert.calledTwice(element.fetch);
+  });
+
+  it('stops refreshing when service removed', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+
+    element.service = '';
+
+    await element.updateComplete;
+    clock.tick(REFRESH_TIME_MS);
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+  });
+
+  it('stops refreshing when element is disconnected', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+
+    document.body.removeChild(element);
+
+    await element.updateComplete;
+    clock.tick(REFRESH_TIME_MS);
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+  });
+
+  it('renders error when thrown', async () => {
+    sinon.stub(element, 'fetch');
+    element.fetch.throws(() => Error('Something went wrong'));
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    // Fetch runs here.
+
+    await element.updateComplete;
+
+    assert.equal(element._error, 'Something went wrong');
+    assert.include(element.shadowRoot.textContent, 'Something went wrong');
+  });
+
+  it('renders fetched announcement', async () => {
+    sinon.stub(element, 'fetch');
+    element.fetch.returns(
+        {announcements: [{id: '1234', messageContent: 'test thing'}]});
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    // Fetch runs here.
+
+    await element.updateComplete;
+
+    assert.deepEqual(element._announcements,
+        [{id: '1234', messageContent: 'test thing'}]);
+    assert.include(element.shadowRoot.textContent, 'test thing');
+  });
+
+  it('renders empty on empty announcement', async () => {
+    sinon.stub(element, 'fetch');
+    element.fetch.returns({});
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    // Fetch runs here.
+
+    await element.updateComplete;
+
+    assert.deepEqual(element._announcements, []);
+    assert.equal(0, element.shadowRoot.children.length);
+  });
+
+  it('fetch returns response data', async () => {
+    const json = {announcements: [{id: '1234', messageContent: 'test thing'}]};
+    const fakeResponse = XSSI_PREFIX + JSON.stringify(json);
+    window.fetch.returns(new window.Response(fakeResponse));
+
+    const resp = await element.fetch('monorail');
+
+    assert.deepEqual(resp, json);
+  });
+
+  it('fetch errors when no XSSI prefix', async () => {
+    const json = {announcements: [{id: '1234', messageContent: 'test thing'}]};
+    const fakeResponse = JSON.stringify(json);
+    window.fetch.returns(new window.Response(fakeResponse));
+
+    try {
+      await element.fetch('monorail');
+    } catch (e) {
+      assert.include(e.message, 'No XSSI prefix in announce response:');
+    }
+  });
+
+  it('fetch errors when response is not okay', async () => {
+    const json = {announcements: [{id: '1234', messageContent: 'test thing'}]};
+    const fakeResponse = XSSI_PREFIX + JSON.stringify(json);
+    window.fetch.returns(new window.Response(fakeResponse, {status: 500}));
+
+    try {
+      await element.fetch('monorail');
+    } catch (e) {
+      assert.include(e.message,
+          'Something went wrong while fetching announcements');
+    }
+  });
+});
diff --git a/static_src/elements/chops/chops-autocomplete/chops-autocomplete.js b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.js
new file mode 100644
index 0000000..dab8f85
--- /dev/null
+++ b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.js
@@ -0,0 +1,632 @@
+// 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} from 'lit-element';
+import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
+
+/**
+ * @type {RegExp} Autocomplete options are matched at word boundaries. This
+ *   Regex specifies what counts as a boundary between words.
+ */
+const DELIMITER_REGEX = /[^a-z0-9]+/i;
+
+/**
+ * Specifies what happens to the input element an autocomplete
+ * instance is attached to when a user selects an autocomplete option. This
+ * constant specifies the default behavior where a form's entire value is
+ * replaced with the selected value.
+ * @param {HTMLInputElement} input An input element.
+ * @param {string} value The value of the selected autocomplete option.
+ */
+const DEFAULT_REPLACER = (input, value) => {
+  input.value = value;
+};
+
+/**
+ * @type {number} The default maximum of completions to render at a time.
+ */
+const DEFAULT_MAX_COMPLETIONS = 200;
+
+/**
+ * @type {number} Globally shared counter for autocomplete instances to help
+ *   ensure that no two <chops-autocomplete> options have the same ID.
+ */
+let idCount = 1;
+
+/**
+ * `<chops-autocomplete>` shared autocomplete UI code that inter-ops with
+ * other code.
+ *
+ * chops-autocomplete inter-ops with any input element, whether custom or
+ * native that can receive change handlers and has a 'value' property which
+ * can be read and set.
+ *
+ * NOTE: This element disables ShadowDOM for accessibility reasons: to allow
+ * aria attributes from the outside to reference features in this element.
+ *
+ * @customElement chops-autocomplete
+ */
+export class ChopsAutocomplete extends LitElement {
+  /** @override */
+  render() {
+    const completions = this.completions;
+    const currentValue = this._prefix.trim().toLowerCase();
+    const index = this._selectedIndex;
+    const currentCompletion = index >= 0 &&
+      index < completions.length ? completions[index] : '';
+
+    return html`
+      <style>
+        /*
+         * Really specific class names are necessary because ShadowDOM
+         * is disabled for this component.
+         */
+        .chops-autocomplete-container {
+          position: relative;
+        }
+        .chops-autocomplete-container table {
+          padding: 0;
+          font-size: var(--chops-main-font-size);
+          color: var(--chops-link-color);
+          position: absolute;
+          background: var(--chops-white);
+          border: var(--chops-accessible-border);
+          z-index: 999;
+          box-shadow: 2px 3px 8px 0px hsla(0, 0%, 0%, 0.3);
+          border-spacing: 0;
+          border-collapse: collapse;
+          /* In the case when the autocomplete extends the
+           * height of the viewport, we want to make sure
+           * there's spacing. */
+          margin-bottom: 1em;
+        }
+        .chops-autocomplete-container tbody {
+          display: block;
+          min-width: 100px;
+          max-height: 500px;
+          overflow: auto;
+        }
+        .chops-autocomplete-container tr {
+          cursor: pointer;
+          transition: background 0.2s ease-in-out;
+        }
+        .chops-autocomplete-container tr[data-selected] {
+          background: var(--chops-active-choice-bg);
+          text-decoration: underline;
+        }
+        .chops-autocomplete-container td {
+          padding: 0.25em 8px;
+          white-space: nowrap;
+        }
+        .screenreader-hidden {
+          clip: rect(1px, 1px, 1px, 1px);
+          height: 1px;
+          overflow: hidden;
+          position: absolute;
+          white-space: nowrap;
+          width: 1px;
+        }
+      </style>
+      <div class="chops-autocomplete-container">
+        <span class="screenreader-hidden" aria-live="polite">
+          ${currentCompletion}
+        </span>
+        <table
+          ?hidden=${!completions.length}
+        >
+          <tbody>
+            ${completions.map((completion, i) => html`
+              <tr
+                id=${completionId(this.id, i)}
+                ?data-selected=${i === index}
+                data-index=${i}
+                data-value=${completion}
+                @mouseover=${this._hoverCompletion}
+                @mousedown=${this._clickCompletion}
+                role="option"
+                aria-selected=${completion.toLowerCase() ===
+                  currentValue ? 'true' : 'false'}
+              >
+                <td class="completion">
+                  ${this._renderCompletion(completion)}
+                </td>
+                <td class="docstring">
+                  ${this._renderDocstring(completion)}
+                </td>
+              </tr>
+            `)}
+          </tbody>
+        </table>
+      </div>
+    `;
+  }
+
+  /**
+   * Renders a single autocomplete result.
+   * @param {string} completion The string for the currently selected
+   *   autocomplete value.
+   * @return {TemplateResult}
+   */
+  _renderCompletion(completion) {
+    const matchDict = this._matchDict;
+
+    if (!(completion in matchDict)) return completion;
+
+    const {index, matchesDoc} = matchDict[completion];
+
+    if (matchesDoc) return completion;
+
+    const prefix = this._prefix;
+    const start = completion.substr(0, index);
+    const middle = completion.substr(index, prefix.length);
+    const end = completion.substr(index + prefix.length);
+
+    return html`${start}<b>${middle}</b>${end}`;
+  }
+
+  /**
+   * Finds the docstring for a given autocomplete result and renders it.
+   * @param {string} completion The autocomplete result rendered.
+   * @return {TemplateResult}
+   */
+  _renderDocstring(completion) {
+    const matchDict = this._matchDict;
+    const docDict = this.docDict;
+
+    if (!completion in docDict) return '';
+
+    const doc = docDict[completion];
+
+    if (!(completion in matchDict)) return doc;
+
+    const {index, matchesDoc} = matchDict[completion];
+
+    if (!matchesDoc) return doc;
+
+    const prefix = this._prefix;
+    const start = doc.substr(0, index);
+    const middle = doc.substr(index, prefix.length);
+    const end = doc.substr(index + prefix.length);
+
+    return html`${start}<b>${middle}</b>${end}`;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * The input this element is for.
+       */
+      for: {type: String},
+      /**
+       * Generated id for the element.
+       */
+      id: {
+        type: String,
+        reflect: true,
+      },
+      /**
+       * The role attribute, set for accessibility.
+       */
+      role: {
+        type: String,
+        reflect: true,
+      },
+      /**
+       * Array of strings for possible autocompletion values.
+       */
+      strings: {type: Array},
+      /**
+       * A dictionary containing optional doc strings for each autocomplete
+       * string.
+       */
+      docDict: {type: Object},
+      /**
+       * An optional function to compute what happens when the user selects
+       * a value.
+       */
+      replacer: {type: Object},
+      /**
+       * An Array of the currently suggested autcomplte values.
+       */
+      completions: {type: Array},
+      /**
+       * Maximum number of completion values that can display at once.
+       */
+      max: {type: Number},
+      /**
+       * Dict of locations of matched substrings. Value format:
+       * {index, matchesDoc}.
+       */
+      _matchDict: {type: Object},
+      _selectedIndex: {type: Number},
+      _prefix: {type: String},
+      _forRef: {type: Object},
+      _boundToggleCompletionsOnFocus: {type: Object},
+      _boundNavigateCompletions: {type: Object},
+      _boundUpdateCompletions: {type: Object},
+      _oldAttributes: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.strings = [];
+    this.docDict = {};
+    this.completions = [];
+    this.max = DEFAULT_MAX_COMPLETIONS;
+
+    this.role = 'listbox';
+    this.id = `chops-autocomplete-${idCount++}`;
+
+    this._matchDict = {};
+    this._selectedIndex = -1;
+    this._prefix = '';
+    this._boundToggleCompletionsOnFocus =
+      this._toggleCompletionsOnFocus.bind(this);
+    this._boundUpdateCompletions = this._updateCompletions.bind(this);
+    this._boundNavigateCompletions = this._navigateCompletions.bind(this);
+    this._oldAttributes = {};
+  }
+
+  // Disable shadow DOM to allow aria attributes to propagate.
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    this._disconnectAutocomplete(this._forRef);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('for')) {
+      const forRef = this.getRootNode().querySelector('#' + this.for);
+
+      // TODO(zhangtiff): Make this element work with custom input components
+      // in the future as well.
+      this._forRef = (forRef.tagName || '').toUpperCase() === 'INPUT' ?
+        forRef : undefined;
+      this._connectAutocomplete(this._forRef);
+    }
+    if (this._forRef) {
+      if (changedProperties.has('id')) {
+        this._forRef.setAttribute('aria-owns', this.id);
+      }
+      if (changedProperties.has('completions')) {
+        // a11y. Tell screenreaders whether the autocomplete is expanded.
+        this._forRef.setAttribute('aria-expanded',
+          this.completions.length ? 'true' : 'false');
+      }
+
+      if (changedProperties.has('_selectedIndex') ||
+          changedProperties.has('completions')) {
+        this._updateAriaActiveDescendant(this._forRef);
+
+        this._scrollCompletionIntoView(this._selectedIndex);
+      }
+    }
+  }
+
+  /**
+   * Sets the aria-activedescendant attribute of the element (ie: an input form)
+   * that the autocomplete is attached to, in order to tell screenreaders about
+   * which autocomplete option is currently selected.
+   * @param {HTMLInputElement} element
+   */
+  _updateAriaActiveDescendant(element) {
+    const i = this._selectedIndex;
+
+    if (i >= 0 && i < this.completions.length) {
+      const selectedId = completionId(this.id, i);
+
+      // a11y. Set the ID of the currently selected element.
+      element.setAttribute('aria-activedescendant', selectedId);
+
+      // Scroll the container to make sure the selected element is in view.
+    } else {
+      element.setAttribute('aria-activedescendant', '');
+    }
+  }
+
+  /**
+   * When a user moves up or down from an autocomplete option that's at the top
+   * or bottom of the autocomplete option container, we must scroll the
+   * container to make sure the user always sees the option they've selected.
+   * @param {number} i The index of the autocomplete option to put into view.
+   */
+  _scrollCompletionIntoView(i) {
+    const selectedId = completionId(this.id, i);
+
+    const container = this.querySelector('tbody');
+    const completion = this.querySelector(`#${selectedId}`);
+
+    if (!completion) return;
+
+    const distanceFromTop = completion.offsetTop - container.scrollTop;
+
+    // If the completion is above the viewport for the container.
+    if (distanceFromTop < 0) {
+      // Position the completion at the top of the container.
+      container.scrollTop = completion.offsetTop;
+    }
+
+    // If the compltion is below the viewport for the container.
+    if (distanceFromTop > (container.offsetHeight - completion.offsetHeight)) {
+      // Position the compltion at the bottom of the container.
+      container.scrollTop = completion.offsetTop - (container.offsetHeight -
+        completion.offsetHeight);
+    }
+  }
+
+  /**
+   * Changes the input's value according to the rules of the replacer function.
+   * @param {string} value - the value to swap in.
+   * @return {undefined}
+   */
+  completeValue(value) {
+    if (!this._forRef) return;
+
+    const replacer = this.replacer || DEFAULT_REPLACER;
+    replacer(this._forRef, value);
+
+    this.hideCompletions();
+  }
+
+  /**
+   * Computes autocomplete values matching the current input in the field.
+   * @return {boolean} Whether any completions were found.
+   */
+  showCompletions() {
+    if (!this._forRef) {
+      this.hideCompletions();
+      return false;
+    }
+    this._prefix = this._forRef.value.trim().toLowerCase();
+    // Always select the first completion by default when recomputing
+    // completions.
+    this._selectedIndex = 0;
+
+    const matchDict = {};
+    const accepted = [];
+    matchDict;
+    for (let i = 0; i < this.strings.length &&
+        accepted.length < this.max; i++) {
+      const s = this.strings[i];
+      let matchIndex = this._matchIndex(this._prefix, s);
+      let matches = matchIndex >= 0;
+      if (matches) {
+        matchDict[s] = {index: matchIndex, matchesDoc: false};
+      } else if (s in this.docDict) {
+        matchIndex = this._matchIndex(this._prefix, this.docDict[s]);
+        matches = matchIndex >= 0;
+        if (matches) {
+          matchDict[s] = {index: matchIndex, matchesDoc: true};
+        }
+      }
+      if (matches) {
+        accepted.push(s);
+      }
+    }
+
+    this._matchDict = matchDict;
+
+    this.completions = accepted;
+
+    return !!this.completions.length;
+  }
+
+  /**
+   * Finds where a given user input matches an autocomplete option. Note that
+   * a match is only found if the substring is at either the beginning of the
+   * string or the beginning of a delimited section of the string. Hence, we
+   * refer to the "needle" in this function a "prefix".
+   * @param {string} prefix The value that the user inputed into the form.
+   * @param {string} s The autocomplete option that's being compared.
+   * @return {number} An integer for what index the substring is found in the
+   *   autocomplete option. Returns -1 if no match.
+   */
+  _matchIndex(prefix, s) {
+    const matchStart = s.toLowerCase().indexOf(prefix.toLocaleLowerCase());
+    if (matchStart === 0 ||
+        (matchStart > 0 && s[matchStart - 1].match(DELIMITER_REGEX))) {
+      return matchStart;
+    }
+    return -1;
+  }
+
+  /**
+   * Hides autocomplete options.
+   */
+  hideCompletions() {
+    this.completions = [];
+    this._prefix = '';
+    this._selectedIndex = -1;
+  }
+
+  /**
+   * Sets an autocomplete option that a user hovers over as the selected option.
+   * @param {MouseEvent} e
+   */
+  _hoverCompletion(e) {
+    const target = e.currentTarget;
+
+    if (!target.dataset || !target.dataset.index) return;
+
+    const index = Number.parseInt(target.dataset.index);
+    if (index >= 0 && index < this.completions.length) {
+      this._selectedIndex = index;
+    }
+  }
+
+  /**
+   * Sets the value of the form input that the user is editing to the
+   * autocomplete option that the user just clicked.
+   * @param {MouseEvent} e
+   */
+  _clickCompletion(e) {
+    e.preventDefault();
+    const target = e.currentTarget;
+    if (!target.dataset || !target.dataset.value) return;
+
+    this.completeValue(target.dataset.value);
+  }
+
+  /**
+   * Hides and shows the autocomplete completions when a user focuses and
+   * unfocuses a form.
+   * @param {FocusEvent} e
+   */
+  _toggleCompletionsOnFocus(e) {
+    const target = e.target;
+
+    // Check if the input is focused or not.
+    if (target.matches(':focus')) {
+      this.showCompletions();
+    } else {
+      this.hideCompletions();
+    }
+  }
+
+  /**
+   * Implements hotkeys to allow the user to navigate autocomplete options with
+   * their keyboard. ie: pressing up and down to select options or Esc to close
+   * the form.
+   * @param {KeyboardEvent} e
+   */
+  _navigateCompletions(e) {
+    const completions = this.completions;
+    if (!completions.length) return;
+
+    switch (e.key) {
+      // TODO(zhangtiff): Throttle or control keyboard navigation so the user
+      // can't navigate faster than they can can perceive.
+      case 'ArrowUp':
+        e.preventDefault();
+        this._navigateUp();
+        break;
+      case 'ArrowDown':
+        e.preventDefault();
+        this._navigateDown();
+        break;
+      case 'Enter':
+      // TODO(zhangtiff): Add Tab to this case as well once all issue detail
+      // inputs use chops-autocomplete.
+        e.preventDefault();
+        if (this._selectedIndex >= 0 &&
+            this._selectedIndex <= completions.length) {
+          this.completeValue(completions[this._selectedIndex]);
+        }
+        break;
+      case 'Escape':
+        e.preventDefault();
+        this.hideCompletions();
+        break;
+    }
+  }
+
+  /**
+   * Selects the completion option above the current one.
+   */
+  _navigateUp() {
+    const completions = this.completions;
+    this._selectedIndex -= 1;
+    if (this._selectedIndex < 0) {
+      this._selectedIndex = completions.length - 1;
+    }
+  }
+
+  /**
+   * Selects the completion option below the current one.
+   */
+  _navigateDown() {
+    const completions = this.completions;
+    this._selectedIndex += 1;
+    if (this._selectedIndex >= completions.length) {
+      this._selectedIndex = 0;
+    }
+  }
+
+  /**
+   * Recomputes autocomplete completions when the user types a new input.
+   * Ignores KeyboardEvents that don't change the input value of the form
+   * to prevent excess recomputations.
+   * @param {KeyboardEvent} e
+   */
+  _updateCompletions(e) {
+    if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
+    this.showCompletions();
+  }
+
+  /**
+   * Initializes the input element that this autocomplete instance is
+   * attached to with aria attributes required for accessibility.
+   * @param {HTMLInputElement} node The input element that the autocomplete is
+   *   attached to.
+   */
+  _connectAutocomplete(node) {
+    if (!node) return;
+
+    node.addEventListener('keyup', this._boundUpdateCompletions);
+    node.addEventListener('keydown', this._boundNavigateCompletions);
+    node.addEventListener('focus', this._boundToggleCompletionsOnFocus);
+    node.addEventListener('blur', this._boundToggleCompletionsOnFocus);
+
+    this._oldAttributes = {
+      'aria-owns': node.getAttribute('aria-owns'),
+      'aria-autocomplete': node.getAttribute('aria-autocomplete'),
+      'aria-expanded': node.getAttribute('aria-expanded'),
+      'aria-haspopup': node.getAttribute('aria-haspopup'),
+      'aria-activedescendant': node.getAttribute('aria-activedescendant'),
+    };
+    node.setAttribute('aria-owns', this.id);
+    node.setAttribute('aria-autocomplete', 'both');
+    node.setAttribute('aria-expanded', 'false');
+    node.setAttribute('aria-haspopup', 'listbox');
+    node.setAttribute('aria-activedescendant', '');
+  }
+
+  /**
+   * When <chops-autocomplete> is disconnected or moved to a difference form,
+   * this function removes the side effects added by <chops-autocomplete> on the
+   * input element that <chops-autocomplete> is attached to.
+   * @param {HTMLInputElement} node The input element that the autocomplete is
+   *   attached to.
+   */
+  _disconnectAutocomplete(node) {
+    if (!node) return;
+
+    node.removeEventListener('keyup', this._boundUpdateCompletions);
+    node.removeEventListener('keydown', this._boundNavigateCompletions);
+    node.removeEventListener('focus', this._boundToggleCompletionsOnFocus);
+    node.removeEventListener('blur', this._boundToggleCompletionsOnFocus);
+
+    for (const key of Object.keys(this._oldAttributes)) {
+      node.setAttribute(key, this._oldAttributes[key]);
+    }
+    this._oldAttributes = {};
+  }
+}
+
+/**
+ * Generates a unique HTML ID for a given autocomplete option, for use by
+ * aria-activedescendant. Note that because the autocomplete element has
+ * ShadowDOM disabled, we need to make sure the ID is specific enough to be
+ * globally unique across the entire application.
+ * @param {string} prefix A unique prefix to differentiate this autocomplete
+ *   instance from other autocomplete instances.
+ * @param {number} i The index of the autocomplete option.
+ * @return {string} A unique HTML ID for a given autocomplete option.
+ */
+function completionId(prefix, i) {
+  return `${prefix}-option-${i}`;
+}
+
+customElements.define('chops-autocomplete', ChopsAutocomplete);
diff --git a/static_src/elements/chops/chops-autocomplete/chops-autocomplete.test.js b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.test.js
new file mode 100644
index 0000000..e470312
--- /dev/null
+++ b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.test.js
@@ -0,0 +1,358 @@
+// 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 sinon from 'sinon';
+import {assert} from 'chai';
+import {ChopsAutocomplete} from './chops-autocomplete.js';
+
+let element;
+let input;
+
+describe('chops-autocomplete', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-autocomplete');
+    document.body.appendChild(element);
+
+    input = document.createElement('input');
+    input.id = 'autocomplete-input';
+    document.body.appendChild(input);
+
+    element.for = 'autocomplete-input';
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    document.body.removeChild(input);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsAutocomplete);
+  });
+
+  it('registers child input', async () => {
+    await element.updateComplete;
+
+    assert.isNotNull(element._forRef);
+    assert.equal(element._forRef.tagName.toUpperCase(), 'INPUT');
+  });
+
+  it('completeValue sets input value', async () => {
+    await element.updateComplete;
+
+    element.completeValue('test');
+    assert.equal(input.value, 'test');
+
+    element.completeValue('again');
+    assert.equal(input.value, 'again');
+  });
+
+  it('completeValue can run a custom replacer', async () => {
+    element.replacer = (input, value) => input.value = value + ',';
+    await element.updateComplete;
+
+    element.completeValue('trailing');
+    assert.equal(input.value, 'trailing,');
+
+    element.completeValue('comma');
+    assert.equal(input.value, 'comma,');
+  });
+
+  it('completions render', async () => {
+    element.completions = ['hello', 'world'];
+    element.docDict = {'hello': 'well hello there'};
+    await element.updateComplete;
+
+    const completions = element.querySelectorAll('.completion');
+    const docstrings = element.querySelectorAll('.docstring');
+
+    assert.equal(completions.length, 2);
+    assert.equal(docstrings.length, 2);
+
+    assert.include(completions[0].textContent, 'hello');
+    assert.include(completions[1].textContent, 'world');
+
+    assert.include(docstrings[0].textContent, 'well hello there');
+    assert.include(docstrings[1].textContent, '');
+  });
+
+  it('completions bold matched section when rendering', async () => {
+    element.completions = ['hello-world'];
+    element._prefix = 'wor';
+    element._matchDict = {
+      'hello-world': {'index': 6},
+    };
+
+    await element.updateComplete;
+
+    const completion = element.querySelector('.completion');
+
+    assert.include(completion.textContent, 'hello-world');
+
+    assert.equal(completion.querySelector('b').textContent.trim(), 'wor');
+  });
+
+
+  it('showCompletions populates completions with matches', async () => {
+    element.strings = [
+      'test-one',
+      'test-two',
+      'ignore',
+      'hello',
+      'woah-test',
+      'i-am-a-tester',
+    ];
+    input.value = 'test';
+    await element.updateComplete;
+
+    element.showCompletions();
+
+    assert.deepEqual(element.completions, [
+      'test-one',
+      'test-two',
+      'woah-test',
+      'i-am-a-tester',
+    ]);
+  });
+
+  it('showCompletions matches docs', async () => {
+    element.strings = [
+      'hello',
+      'world',
+      'no-op',
+    ];
+    element.docDict = {'world': 'this is a test'};
+    input.value = 'test';
+    await element.updateComplete;
+
+    element.showCompletions();
+
+    assert.deepEqual(element.completions, [
+      'world',
+    ]);
+  });
+
+  it('showCompletions caps completions at max', async () => {
+    element.max = 2;
+    element.strings = [
+      'test-one',
+      'test-two',
+      'ignore',
+      'hello',
+      'woah-test',
+      'i-am-a-tester',
+    ];
+    input.value = 'test';
+    await element.updateComplete;
+
+    element.showCompletions();
+
+    assert.deepEqual(element.completions, [
+      'test-one',
+      'test-two',
+    ]);
+  });
+
+  it('hideCompletions hides completions', async () => {
+    element.completions = [
+      'test-one',
+      'test-two',
+    ];
+
+    await element.updateComplete;
+
+    const completionTable = element.querySelector('table');
+    assert.isFalse(completionTable.hidden);
+
+    element.hideCompletions();
+
+    await element.updateComplete;
+
+    assert.isTrue(completionTable.hidden);
+  });
+
+  it('clicking completion completes it', async () => {
+    element.completions = [
+      'test-one',
+      'test-two',
+      'click me!',
+      'test',
+    ];
+
+    await element.updateComplete;
+
+    const completions = element.querySelectorAll('tr');
+
+    assert.equal(input.value, '');
+
+    // Note: the click() event can only trigger click events, not mousedown
+    // events, so we are instead manually running the event handler.
+    element._clickCompletion({
+      preventDefault: sinon.stub(),
+      currentTarget: completions[2],
+    });
+
+    assert.equal(input.value, 'click me!');
+  });
+
+  it('completion is scrolled into view when outside viewport', async () => {
+    element.completions = [
+      'i',
+      'am',
+      'an option',
+    ];
+    element._selectedIndex = 0;
+    element.id = 'chops-autocomplete-1';
+
+    await element.updateComplete;
+
+    const container = element.querySelector('tbody');
+    const completion = container.querySelector('tr');
+    const completionHeight = completion.offsetHeight;
+    // Make the table one row tall.
+    container.style.height = `${completionHeight}px`;
+
+    element._selectedIndex = 1;
+    await element.updateComplete;
+
+    assert.equal(container.scrollTop, completionHeight);
+
+    element._selectedIndex = 2;
+    await element.updateComplete;
+
+    assert.equal(container.scrollTop, completionHeight * 2);
+
+    element._selectedIndex = 0;
+    await element.updateComplete;
+
+    assert.equal(container.scrollTop, 0);
+  });
+
+  it('aria-activedescendant set based on selected option', async () => {
+    element.completions = [
+      'i',
+      'am',
+      'an option',
+    ];
+    element._selectedIndex = 1;
+    element.id = 'chops-autocomplete-1';
+
+    await element.updateComplete;
+
+    assert.equal(input.getAttribute('aria-activedescendant'),
+        'chops-autocomplete-1-option-1');
+  });
+
+  it('hovering over a completion selects it', async () => {
+    element.completions = [
+      'hover',
+      'over',
+      'me',
+    ];
+
+    await element.updateComplete;
+
+    const completions = element.querySelectorAll('tr');
+
+    element._hoverCompletion({
+      currentTarget: completions[2],
+    });
+
+    assert.equal(element._selectedIndex, 2);
+
+    element._hoverCompletion({
+      currentTarget: completions[1],
+    });
+
+    assert.equal(element._selectedIndex, 1);
+  });
+
+  it('ArrowDown moves through completions', async () => {
+    element.completions = [
+      'move',
+      'down',
+      'me',
+    ];
+
+    element._selectedIndex = 0;
+
+    await element.updateComplete;
+
+    const preventDefault = sinon.stub();
+
+    element._navigateCompletions({preventDefault, key: 'ArrowDown'});
+    assert.equal(element._selectedIndex, 1);
+
+    element._navigateCompletions({preventDefault, key: 'ArrowDown'});
+    assert.equal(element._selectedIndex, 2);
+
+    // Wrap around.
+    element._navigateCompletions({preventDefault, key: 'ArrowDown'});
+    assert.equal(element._selectedIndex, 0);
+
+    sinon.assert.callCount(preventDefault, 3);
+  });
+
+  it('ArrowUp moves through completions', async () => {
+    element.completions = [
+      'move',
+      'up',
+      'me',
+    ];
+
+    element._selectedIndex = 0;
+
+    await element.updateComplete;
+
+    const preventDefault = sinon.stub();
+
+    // Wrap around.
+    element._navigateCompletions({preventDefault, key: 'ArrowUp'});
+    assert.equal(element._selectedIndex, 2);
+
+    element._navigateCompletions({preventDefault, key: 'ArrowUp'});
+    assert.equal(element._selectedIndex, 1);
+
+    element._navigateCompletions({preventDefault, key: 'ArrowUp'});
+    assert.equal(element._selectedIndex, 0);
+
+    sinon.assert.callCount(preventDefault, 3);
+  });
+
+  it('Enter completes with selected completion', async () => {
+    element.completions = [
+      'hello',
+      'pick me',
+      'world',
+    ];
+
+    element._selectedIndex = 1;
+
+    await element.updateComplete;
+
+    const preventDefault = sinon.stub();
+
+    element._navigateCompletions({preventDefault, key: 'Enter'});
+
+    assert.equal(input.value, 'pick me');
+    sinon.assert.callCount(preventDefault, 1);
+  });
+
+  it('Escape hides completions', async () => {
+    element.completions = [
+      'hide',
+      'me',
+    ];
+
+    await element.updateComplete;
+
+    const preventDefault = sinon.stub();
+    element._navigateCompletions({preventDefault, key: 'Escape'});
+
+    sinon.assert.callCount(preventDefault, 1);
+
+    await element.updateComplete;
+
+    assert.equal(element.completions.length, 0);
+  });
+});
diff --git a/static_src/elements/chops/chops-button/chops-button.js b/static_src/elements/chops/chops-button/chops-button.js
new file mode 100644
index 0000000..2139e22
--- /dev/null
+++ b/static_src/elements/chops/chops-button/chops-button.js
@@ -0,0 +1,112 @@
+// 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';
+
+/**
+ * `<chops-button>` displays a styled button component with a few niceties.
+ *
+ * @customElement chops-button
+ * @demo /demo/chops-button_demo.html
+ */
+export class ChopsButton extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        --chops-button-padding: 0.5em 16px;
+        background: hsla(0, 0%, 95%, 1);
+        margin: 0.25em 4px;
+        cursor: pointer;
+        border-radius: 3px;
+        text-align: center;
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        user-select: none;
+        transition: filter 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
+        font-family: var(--chops-font-family);
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      :host([raised]) {
+        box-shadow: 0px 2px 8px -1px hsla(0, 0%, 0%, 0.5);
+      }
+      :host(:hover) {
+        filter: brightness(95%);
+      }
+      :host(:active) {
+        filter: brightness(115%);
+      }
+      :host([raised]:active) {
+        box-shadow: 0px 1px 8px -1px hsla(0, 0%, 0%, 0.5);
+      }
+      :host([disabled]),
+      :host([disabled]:hover) {
+        filter: grayscale(30%);
+        opacity: 0.4;
+        background: hsla(0, 0%, 87%, 1);
+        cursor: default;
+        pointer-events: none;
+        box-shadow: none;
+      }
+      button {
+        background: none;
+        width: 100%;
+        height: 100%;
+        border: 0;
+        padding: var(--chops-button-padding);
+        margin: 0;
+        color: inherit;
+        cursor: inherit;
+        text-align: center;
+        font-family: inherit;
+        text-align: inherit;
+        font-weight: inherit;
+        font-size: inherit;
+        line-height: inherit;
+        border-radius: inherit;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <button ?disabled=${this.disabled}>
+        <slot></slot>
+      </button>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /** Whether the button is available for input or not. */
+      disabled: {
+        type: Boolean,
+        reflect: true,
+      },
+      /** Whether the button should have a shadow or not. */
+      raised: {
+        type: Boolean,
+        value: false,
+        reflect: true,
+      },
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.disabled = false;
+    this.raised = false;
+  }
+}
+customElements.define('chops-button', ChopsButton);
diff --git a/static_src/elements/chops/chops-button/chops-button.test.js b/static_src/elements/chops/chops-button/chops-button.test.js
new file mode 100644
index 0000000..4487564
--- /dev/null
+++ b/static_src/elements/chops/chops-button/chops-button.test.js
@@ -0,0 +1,45 @@
+// 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 {assert} from 'chai';
+import {ChopsButton} from './chops-button.js';
+import {auditA11y} from 'shared/test/helpers';
+
+let element;
+
+describe('chops-button', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-button');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsButton);
+  });
+
+  it('initial a11y', async () => {
+    const text = document.createTextNode('button text');
+    element.appendChild(text);
+    await auditA11y(element);
+  });
+
+  it('chops-button can be disabled', async () => {
+    await element.updateComplete;
+
+    const innerButton = element.shadowRoot.querySelector('button');
+
+    assert.isFalse(element.hasAttribute('disabled'));
+    assert.isFalse(innerButton.hasAttribute('disabled'));
+
+    element.disabled = true;
+    await element.updateComplete;
+
+    assert.isTrue(element.hasAttribute('disabled'));
+    assert.isTrue(innerButton.hasAttribute('disabled'));
+  });
+});
diff --git a/static_src/elements/chops/chops-checkbox/chops-checkbox.js b/static_src/elements/chops/chops-checkbox/chops-checkbox.js
new file mode 100644
index 0000000..d752347
--- /dev/null
+++ b/static_src/elements/chops/chops-checkbox/chops-checkbox.js
@@ -0,0 +1,135 @@
+// 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';
+
+/**
+ * `<chops-checkbox>`
+ *
+ * A checkbox component. This component is primarily a wrapper
+ * around a native checkbox to allow easy sharing of styles.
+ *
+ */
+export class ChopsCheckbox extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        --chops-checkbox-color: var(--chops-primary-accent-color);
+        /* A bit brighter than Chrome's default focus color to
+        * avoid blending into the checkbox's blue. */
+        --chops-checkbox-focus-color: hsl(193, 82%, 63%);
+        --chops-checkbox-size: 16px;
+        --chops-checkbox-check-size: 18px;
+      }
+      label {
+        cursor: pointer;
+        display: inline-flex;
+        align-items: center;
+      }
+      input[type="checkbox"] {
+        /* We need the checkbox to be hidden but still accessible. */
+        opacity: 0;
+        width: 0;
+        height: 0;
+        position: absolute;
+        top: -9999;
+        left: -9999;
+      }
+      label::before {
+        width: var(--chops-checkbox-size);
+        height: var(--chops-checkbox-size);
+        margin-right: 8px;
+        box-sizing: border-box;
+        content: "\\2713";
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        border: 2px solid #222;
+        border-radius: 2px;
+        background: #fff;
+        font-size: var(--chops-checkbox-check-size);
+        padding: 0;
+        color: transparent;
+      }
+      input[type="checkbox"]:focus + label::before {
+        /* Make sure an outline shows around this element for
+        * accessibility.
+        */
+        box-shadow: 0 0 5px 1px var(--chops-checkbox-focus-color);
+      }
+      input[type="checkbox"]:checked + label::before {
+        background: var(--chops-checkbox-color);
+        border-color: var(--chops-checkbox-color);
+        color: #fff;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <!-- Note: Avoiding 2-way data binding to futureproof this code
+        for LitElement. -->
+      <input id="checkbox" type="checkbox"
+        .checked=${this.checked} @change=${this._checkedChangeHandler}>
+      <label for="checkbox">
+        <slot></slot>
+      </label>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      label: {type: String},
+
+      /**
+       * Note: At the moment, this component does not manage its own
+       * internal checked state. It expects its checked state to come
+       * from its parent, and its parent is expected to update the
+       * chops-checkbox's checked state on a change event.
+       *
+       * This can be generalized in the future to support multiple
+       * ways of managing checked state if needed.
+       **/
+      checked: {type: Boolean},
+    };
+  }
+
+  /**
+   * Clicks the checkbox. Helpful for automated testing.
+   */
+  click() {
+    super.click();
+    /** @type {HTMLInputElement} */ (
+      this.shadowRoot.querySelector('#checkbox')).click();
+  }
+
+  /**
+   * Listens to the native checkbox's change event and runs internal
+   * logic based on changes.
+   * @param {Event} evt
+   * @private
+   */
+  _checkedChangeHandler(evt) {
+    this._checkedChange(evt.target.checked);
+  }
+
+  /**
+   * @param {boolean} checked Whether the box was checked or unchecked.
+   * @fires CustomEvent#checked-change
+   * @private
+   */
+  _checkedChange(checked) {
+    if (checked === this.checked) return;
+    const customEvent = new CustomEvent('checked-change', {
+      detail: {
+        checked: checked,
+      },
+    });
+    this.dispatchEvent(customEvent);
+  }
+}
+customElements.define('chops-checkbox', ChopsCheckbox);
diff --git a/static_src/elements/chops/chops-checkbox/chops-checkbox.test.js b/static_src/elements/chops/chops-checkbox/chops-checkbox.test.js
new file mode 100644
index 0000000..5a11111
--- /dev/null
+++ b/static_src/elements/chops/chops-checkbox/chops-checkbox.test.js
@@ -0,0 +1,86 @@
+// 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 {assert} from 'chai';
+import sinon from 'sinon';
+import {ChopsCheckbox} from './chops-checkbox.js';
+
+let element;
+
+describe('chops-checkbox', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-checkbox');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsCheckbox);
+  });
+
+  it('clicking checkbox dispatches checked-change event', async () => {
+    element.checked = false;
+    sinon.stub(window, 'CustomEvent');
+    sinon.stub(element, 'dispatchEvent');
+
+    await element.updateComplete;
+
+    element.shadowRoot.querySelector('#checkbox').click();
+
+    assert.deepEqual(window.CustomEvent.args[0][0], 'checked-change');
+    assert.deepEqual(window.CustomEvent.args[0][1], {
+      detail: {checked: true},
+    });
+
+    assert.isTrue(window.CustomEvent.calledOnce);
+    assert.isTrue(element.dispatchEvent.calledOnce);
+
+    window.CustomEvent.restore();
+    element.dispatchEvent.restore();
+  });
+
+  it('updating checked property updates native <input>', async () => {
+    element.checked = false;
+
+    await element.updateComplete;
+
+    assert.isFalse(element.checked);
+    assert.isFalse(element.shadowRoot.querySelector('input').checked);
+
+    element.checked = true;
+
+    await element.updateComplete;
+
+    assert.isTrue(element.checked);
+    assert.isTrue(element.shadowRoot.querySelector('input').checked);
+  });
+
+  it('updating checked attribute updates native <input>', async () => {
+    element.setAttribute('checked', true);
+    await element.updateComplete;
+
+    assert.equal(element.getAttribute('checked'), 'true');
+    assert.isTrue(element.shadowRoot.querySelector('input').checked);
+
+    element.click();
+    await element.updateComplete;
+
+    // We expect the 'checked' attribute to remain the same even as the
+    // corresponding property changes when the user clicks the checkbox.
+    assert.equal(element.getAttribute('checked'), 'true');
+    assert.isFalse(element.shadowRoot.querySelector('input').checked);
+
+    element.click();
+    await element.updateComplete;
+    assert.isTrue(element.shadowRoot.querySelector('input').checked);
+
+    element.removeAttribute('checked');
+    await element.updateComplete;
+    assert.isNotTrue(element.getAttribute('checked'));
+    assert.isFalse(element.shadowRoot.querySelector('input').checked);
+  });
+});
diff --git a/static_src/elements/chops/chops-chip/chops-chip.js b/static_src/elements/chops/chops-chip/chops-chip.js
new file mode 100644
index 0000000..ce8319e
--- /dev/null
+++ b/static_src/elements/chops/chops-chip/chops-chip.js
@@ -0,0 +1,122 @@
+// 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';
+
+/**
+ * `<chops-chip>` displays a chip.
+ * "Chips are compact elements that represent an input, attribute, or action."
+ * https://material.io/components/chips/
+ */
+export class ChopsChip extends LitElement {
+  /** @override */
+  static get properties() {
+    return {
+      focusable: {type: Boolean, reflect: true},
+      thumbnail: {type: String},
+      buttonIcon: {type: String},
+      buttonLabel: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** @type {boolean} */
+    this.focusable = false;
+
+    /** @type {string} */
+    this.thumbnail = '';
+
+    /** @type {string} */
+    this.buttonIcon = '';
+    /** @type {string} */
+    this.buttonLabel = '';
+  }
+
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        --chops-chip-bg-color: var(--chops-blue-gray-50);
+        display: inline-flex;
+        padding: 0px 10px;
+        line-height: 22px;
+        margin: 0 2px;
+        border-radius: 12px;
+        background: var(--chops-chip-bg-color);
+        align-items: center;
+        font-size: var(--chops-main-font-size);
+        box-sizing: border-box;
+        border: 1px solid var(--chops-chip-bg-color);
+      }
+      :host(:focus), :host(.selected) {
+        background: var(--chops-active-choice-bg);
+        border: 1px solid var(--chops-light-accent-color);
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      i.left {
+        margin: 0 4px 0 -6px;
+      }
+      button {
+        border-radius: 50%;
+        cursor: pointer;
+        background: none;
+        border: 0;
+        padding: 0;
+        margin: 0 -6px 0 4px;
+        display: inline-flex;
+        align-items: center;
+        transition: background-color 0.2s ease-in-out;
+      }
+      button[hidden] {
+        display: none;
+      }
+      button:hover {
+        background: var(--chops-gray-300);
+      }
+      i.material-icons {
+        color: var(--chops-primary-icon-color);
+        font-size: 14px;
+        user-select: none;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      ${this.thumbnail ? html`
+        <i class="material-icons left">${this.thumbnail}</i>
+      ` : ''}
+      <slot></slot>
+      ${this.buttonIcon ? html`
+        <button @click=${this.clickButton} aria-label=${this.buttonLabel}>
+          <i class="material-icons" aria-hidden="true"}>${this.buttonIcon}</i>
+        </button>
+      `: ''}
+    `;
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('focusable')) {
+      this.tabIndex = this.focusable ? '0' : undefined;
+    }
+    super.update(changedProperties);
+  }
+
+  /**
+   * @param {MouseEvent} e A click event.
+   * @fires CustomEvent#click-button
+   */
+  clickButton(e) {
+    this.dispatchEvent(new CustomEvent('click-button'));
+  }
+}
+customElements.define('chops-chip', ChopsChip);
diff --git a/static_src/elements/chops/chops-chip/chops-chip.test.js b/static_src/elements/chops/chops-chip/chops-chip.test.js
new file mode 100644
index 0000000..843000b
--- /dev/null
+++ b/static_src/elements/chops/chops-chip/chops-chip.test.js
@@ -0,0 +1,52 @@
+// 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 {assert} from 'chai';
+import sinon from 'sinon';
+import {ChopsChip} from './chops-chip.js';
+
+let element;
+
+describe('chops-chip', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-chip');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsChip);
+  });
+
+  it('icon is visible when defined', async () => {
+    await element.updateComplete;
+    assert.isNull(element.shadowRoot.querySelector('button'));
+
+    element.buttonIcon = 'close';
+
+    await element.updateComplete;
+
+    assert.isNotNull(element.shadowRoot.querySelector('button'));
+  });
+
+  it('clicking icon fires event', async () => {
+    const onClickStub = sinon.stub();
+
+    element.buttonIcon = 'close';
+
+    await element.updateComplete;
+
+    element.addEventListener('click-button', onClickStub);
+
+    assert.isFalse(onClickStub.calledOnce);
+
+    const icon = element.shadowRoot.querySelector('button');
+    icon.click();
+
+    assert.isTrue(onClickStub.calledOnce);
+  });
+});
diff --git a/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.js b/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.js
new file mode 100644
index 0000000..e300588
--- /dev/null
+++ b/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.js
@@ -0,0 +1,133 @@
+// 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 'elements/chops/chops-button/chops-button.js';
+
+/**
+ * @typedef {Object} ChoiceOption
+ * @property {string=} value a unique string identifier for this option.
+ * @property {string=} text the text displayed to the user for this option.
+ * @property {string=} url the url this option navigates to.
+ */
+
+/**
+ * Shared component for rendering a set of choice chips.
+ * @extends {LitElement}
+ */
+export class ChopsChoiceButtons extends LitElement {
+  /** @override */
+  render() {
+    return html`
+      ${(this.options).map((option) => this._renderOption(option))}
+    `;
+  }
+
+  /**
+   * Rendering helper for rendering a single option.
+   * @param {ChoiceOption} option
+   * @return {TemplateResult}
+   */
+  _renderOption(option) {
+    const isSelected = this.value === option.value;
+    if (option.url) {
+      return html`
+        <a
+          ?selected=${isSelected}
+          aria-current=${isSelected ? 'true' : 'false'}
+          href=${option.url}
+        >${option.text}</a>
+      `;
+    }
+    return html`
+      <button
+        ?selected=${isSelected}
+        aria-current=${isSelected ? 'true' : 'false'}
+        @click=${this._setValue}
+        value=${option.value}
+      >${option.text}</button>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Array of options where each option is an Object with keys:
+       * {value, text, url}
+       */
+      options: {type: Array},
+      /**
+       * Which button is currently selected.
+       */
+      value: {type: String},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+
+    /**
+     * @type {Array<ChoiceOption>}
+     */
+    this.options = [];
+    this.value = '';
+  };
+
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: grid;
+        grid-auto-flow: column;
+        grid-template-columns: auto;
+      }
+      button, a {
+        display: block;
+        cursor: pointer;
+        border: 0;
+        color: var(--chops-gray-700);
+        font-weight: var(--chops-link-font-weight);
+        font-size: var(--chops-normal-font-size);
+        margin: 0.1em 4px;
+        padding: 4px 10px;
+        line-height: 1.4;
+        background: var(--chops-choice-bg);
+        text-decoration: none;
+        border-radius: 16px;
+      }
+      button[selected], a[selected] {
+        background: var(--chops-active-choice-bg);
+        color: var(--chops-link-color);
+        font-weight: var(--chops-link-font-weight);
+        border-radius: 16px;
+      }
+    `;
+  };
+
+  /**
+   * Public method for allowing parents to change the value of this component.
+   * @param {string} newValue
+   * @fires CustomEvent#change
+   */
+  setValue(newValue) {
+    if (newValue !== this.value) {
+      this.value = newValue;
+      this.dispatchEvent(new CustomEvent('change'));
+    }
+  }
+
+  /**
+   * Private setter for updating the value of the component based on an internal
+   * click event.
+   * @param {MouseEvent} e
+   * @private
+   */
+  _setValue(e) {
+    this.setValue(e.target.getAttribute('value'));
+  }
+};
+
+customElements.define('chops-choice-buttons', ChopsChoiceButtons);
diff --git a/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.test.js b/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.test.js
new file mode 100644
index 0000000..e529735
--- /dev/null
+++ b/static_src/elements/chops/chops-choice-buttons/chops-choice-buttons.test.js
@@ -0,0 +1,99 @@
+// 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 {assert} from 'chai';
+import sinon from 'sinon';
+import {ChopsChoiceButtons} from './chops-choice-buttons';
+
+let element;
+
+describe('chops-choice-buttons', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-choice-buttons');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsChoiceButtons);
+  });
+
+  it('clicking option fires change event', async () => {
+    element.options = [{value: 'test', text: 'click me'}];
+    element.value = '';
+
+    await element.updateComplete;
+
+    const changeStub = sinon.stub();
+    element.addEventListener('change', changeStub);
+
+    const option = element.shadowRoot.querySelector('button');
+    option.click();
+
+    sinon.assert.calledOnce(changeStub);
+  });
+
+  it('clicking selected value does not fire change event', async () => {
+    element.options = [{value: 'test', text: 'click me'}];
+    element.value = 'test';
+
+    await element.updateComplete;
+
+    const changeStub = sinon.stub();
+    element.addEventListener('change', changeStub);
+
+    const option = element.shadowRoot.querySelector('button');
+    option.click();
+
+    sinon.assert.notCalled(changeStub);
+  });
+
+  it('selected value highlighted and has aria-current="true"', async () => {
+    element.options = [
+      {value: 'test', text: 'test'},
+      {value: 'selected', text: 'highlighted!'},
+    ];
+    element.value = 'selected';
+
+    await element.updateComplete;
+
+    const options = element.shadowRoot.querySelectorAll('button');
+
+    assert.isFalse(options[0].hasAttribute('selected'));
+    assert.isTrue(options[1].hasAttribute('selected'));
+
+    assert.equal(options[0].getAttribute('aria-current'), 'false');
+    assert.equal(options[1].getAttribute('aria-current'), 'true');
+  });
+
+  it('renders <a> tags when url set', async () => {
+    element.options = [
+      {value: 'test', text: 'test', url: 'http://google.com/'},
+    ];
+
+    await element.updateComplete;
+
+    const options = element.shadowRoot.querySelectorAll('a');
+
+    assert.equal(options[0].textContent.trim(), 'test');
+    assert.equal(options[0].href, 'http://google.com/');
+  });
+
+  it('selected value highlighted for <a> tags', async () => {
+    element.options = [
+      {value: 'test', text: 'test', url: 'http://google.com/'},
+      {value: 'selected', text: 'highlighted!', url: 'http://localhost/'},
+    ];
+    element.value = 'selected';
+
+    await element.updateComplete;
+
+    const options = element.shadowRoot.querySelectorAll('a');
+
+    assert.isFalse(options[0].hasAttribute('selected'));
+    assert.isTrue(options[1].hasAttribute('selected'));
+  });
+});
diff --git a/static_src/elements/chops/chops-collapse/chops-collapse.js b/static_src/elements/chops/chops-collapse/chops-collapse.js
new file mode 100644
index 0000000..0df3e21
--- /dev/null
+++ b/static_src/elements/chops/chops-collapse/chops-collapse.js
@@ -0,0 +1,62 @@
+// 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';
+
+/**
+ * `<chops-collapse>` displays a collapsible element.
+ *
+ */
+export class ChopsCollapse extends LitElement {
+  /** @override */
+  static get properties() {
+    return {
+      opened: {
+        type: Boolean,
+        reflect: true,
+      },
+      ariaHidden: {
+        attribute: 'aria-hidden',
+        type: Boolean,
+        reflect: true,
+      },
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return css`
+      :host, :host([hidden]) {
+        display: none;
+      }
+      :host([opened]) {
+        display: block;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <slot></slot>
+    `;
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.opened = false;
+    this.ariaHidden = true;
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('opened')) {
+      this.ariaHidden = !this.opened;
+    }
+    super.update(changedProperties);
+  }
+}
+customElements.define('chops-collapse', ChopsCollapse);
diff --git a/static_src/elements/chops/chops-collapse/chops-collapse.test.js b/static_src/elements/chops/chops-collapse/chops-collapse.test.js
new file mode 100644
index 0000000..7058b65
--- /dev/null
+++ b/static_src/elements/chops/chops-collapse/chops-collapse.test.js
@@ -0,0 +1,33 @@
+// 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 {assert} from 'chai';
+import {ChopsCollapse} from './chops-collapse.js';
+
+
+let element;
+describe('chops-collapse', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-collapse');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsCollapse);
+  });
+
+  it('toggling chops-collapse changes aria-hidden', () => {
+    element.opened = true;
+
+    assert.isNull(element.getAttribute('aria-hidden'));
+
+    element.opened = false;
+
+    assert.isDefined(element.getAttribute('aria-hidden'));
+  });
+});
diff --git a/static_src/elements/chops/chops-dialog/chops-dialog.js b/static_src/elements/chops/chops-dialog/chops-dialog.js
new file mode 100644
index 0000000..0d40aa2
--- /dev/null
+++ b/static_src/elements/chops/chops-dialog/chops-dialog.js
@@ -0,0 +1,254 @@
+// 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';
+
+/**
+ * `<chops-dialog>` displays a modal/dialog overlay.
+ *
+ * @customElement
+ */
+export class ChopsDialog extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        position: fixed;
+        z-index: 9999;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+        overflow: auto;
+        background-color: rgba(0,0,0,0.4);
+        display: flex;
+        align-items: center;
+        justify-content: center;
+      }
+      :host(:not([opened])), [hidden] {
+        display: none;
+        visibility: hidden;
+      }
+      :host([closeOnOutsideClick]),
+      :host([closeOnOutsideClick]) .dialog::backdrop {
+        /* TODO(zhangtiff): Deprecate custom backdrop in favor of native
+        * browser backdrop.
+        */
+        cursor: pointer;
+      }
+      .dialog {
+        background: none;
+        border: 0;
+        max-width: 90%;
+      }
+      .dialog-content {
+        /* This extra div is here because otherwise the browser can't
+        * differentiate between a click event that hits the dialog element or
+        * its backdrop pseudoelement.
+        */
+        box-sizing: border-box;
+        background: var(--chops-white);
+        padding: 1em 16px;
+        cursor: default;
+        box-shadow: 0px 3px 20px 0px hsla(0, 0%, 0%, 0.4);
+        width: var(--chops-dialog-width);
+        max-width: var(--chops-dialog-max-width, 100%);
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <dialog class="dialog" role="dialog" @cancel=${this._cancelHandler}>
+        <div class="dialog-content">
+          <slot></slot>
+        </div>
+      </dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Whether the dialog should currently be displayed or not.
+       */
+      opened: {
+        type: Boolean,
+        reflect: true,
+      },
+      /**
+       * A boolean that determines whether clicking outside of the dialog
+       * window should close it.
+       */
+      closeOnOutsideClick: {
+        type: Boolean,
+      },
+      /**
+       * A function fired when the element tries to change its own opened
+       * state. This is useful if you want the dialog state managed outside
+       * of the dialog instead of with internal state. (ie: with Redux)
+       */
+      onOpenedChange: {
+        type: Object,
+      },
+      /**
+       * When True, disables exiting keys and closing on outside clicks.
+       * Forces the user to interact with the dialog rather than just dismissing
+       * it.
+       */
+      forced: {
+        type: Boolean,
+      },
+      _boundKeydownHandler: {
+        type: Object,
+      },
+      _previousFocusedElement: {
+        type: Object,
+      },
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.opened = false;
+    this.closeOnOutsideClick = false;
+    this.forced = false;
+    this._boundKeydownHandler = this._keydownHandler.bind(this);
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    this.addEventListener('click', (evt) => {
+      if (!this.opened || !this.closeOnOutsideClick || this.forced) return;
+
+      const hasDialog = evt.composedPath().find(
+          (node) => {
+            return node.classList && node.classList.contains('dialog-content');
+          }
+      );
+      if (hasDialog) return;
+
+      this.close();
+    });
+
+    window.addEventListener('keydown', this._boundKeydownHandler, true);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    window.removeEventListener('keydown', this._boundKeydownHandler,
+        true);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('opened')) {
+      this._openedChanged(this.opened);
+    }
+  }
+
+  _keydownHandler(event) {
+    if (!this.opened) return;
+    if (event.key === 'Escape' && this.forced) {
+      // Stop users from using the Escape key in a forced dialog.
+      e.preventDefault();
+    }
+  }
+
+  /**
+   * Closes the dialog.
+   * May have its logic overridden by a custom onOpenChanged function.
+   */
+  close() {
+    if (this.onOpenedChange) {
+      this.onOpenedChange(false);
+    } else {
+      this.opened = false;
+    }
+  }
+
+  /**
+   * Opens the dialog.
+   * May have its logic overridden by a custom onOpenChanged function.
+   */
+  open() {
+    if (this.onOpenedChange) {
+      this.onOpenedChange(true);
+    } else {
+      this.opened = true;
+    }
+  }
+
+  /**
+   * Switches the dialog from open to closed or vice versa.
+   */
+  toggle() {
+    this.opened = !this.opened;
+  }
+
+  _cancelHandler(evt) {
+    if (!this.forced) {
+      this.close();
+    } else {
+      evt.preventDefault();
+    }
+  }
+
+  _getActiveElement() {
+    // document.activeElement alone isn't sufficient to find the active
+    // element within shadow dom.
+    let active = document.activeElement || document.body;
+    let activeRoot = active.shadowRoot || active.root;
+    while (activeRoot && activeRoot.activeElement) {
+      active = activeRoot.activeElement;
+      activeRoot = active.shadowRoot || active.root;
+    }
+    return active;
+  }
+
+  _openedChanged(opened) {
+    const dialog = this.shadowRoot.querySelector('dialog');
+    if (opened) {
+      // For accessibility, we want to ensure we remember the element that was
+      // focused before this dialog opened.
+      this._previousFocusedElement = this._getActiveElement();
+
+      if (dialog.showModal) {
+        dialog.showModal();
+      } else {
+        dialog.setAttribute('open', 'true');
+      }
+      if (this._previousFocusedElement) {
+        this._previousFocusedElement.blur();
+      }
+    } else {
+      if (dialog.close) {
+        dialog.close();
+      } else {
+        dialog.setAttribute('open', undefined);
+      }
+
+      if (this._previousFocusedElement) {
+        const element = this._previousFocusedElement;
+        requestAnimationFrame(() => {
+          // HACK. This is to prevent a possible accessibility bug where
+          // using a keypress to trigger a button that exits a modal causes
+          // the modal to immediately re-open because the button that
+          // originally opened the modal refocuses, and the keypress
+          // propagates.
+          element.focus();
+        });
+      }
+    }
+  }
+}
+
+customElements.define('chops-dialog', ChopsDialog);
diff --git a/static_src/elements/chops/chops-dialog/chops-dialog.test.js b/static_src/elements/chops/chops-dialog/chops-dialog.test.js
new file mode 100644
index 0000000..376496a
--- /dev/null
+++ b/static_src/elements/chops/chops-dialog/chops-dialog.test.js
@@ -0,0 +1,37 @@
+// 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 {expect, assert} from 'chai';
+import {ChopsDialog} from './chops-dialog.js';
+
+let element;
+
+describe('chops-dialog', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-dialog');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsDialog);
+  });
+
+  it('chops-dialog is visible when open', async () => {
+    element.opened = false;
+
+    await element.updateComplete;
+
+    expect(element).not.to.be.visible;
+
+    element.opened = true;
+
+    await element.updateComplete;
+
+    expect(element).to.be.visible;
+  });
+});
diff --git a/static_src/elements/chops/chops-filter-chips/chops-filter-chips.js b/static_src/elements/chops/chops-filter-chips/chops-filter-chips.js
new file mode 100644
index 0000000..3bcc0c6
--- /dev/null
+++ b/static_src/elements/chops/chops-filter-chips/chops-filter-chips.js
@@ -0,0 +1,70 @@
+// Copyright 2020 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 'elements/chops/chops-chip/chops-chip.js';
+
+/**
+ * `<chops-filter-chips>` displays a set of filter chips.
+ * https://material.io/components/chips/#filter-chips
+ */
+export class ChopsFilterChips extends LitElement {
+  /** @override */
+  static get properties() {
+    return {
+      options: {type: Array},
+      selected: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    /** @type {Array<string>} */
+    this.options = [];
+    /** @type {Object<string, boolean>} */
+    this.selected = {};
+  }
+
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: inline-flex;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`${this.options.map((option) => this._renderChip(option))}`;
+  }
+
+  /**
+   * Render a single chip.
+   * @param {string} option The text on the chip.
+   * @return {TemplateResult}
+   */
+  _renderChip(option) {
+    return html`
+      <chops-chip
+          @click=${this.select.bind(this, option)}
+          class=${this.selected[option] ? 'selected' : ''}
+          .thumbnail=${this.selected[option] ? 'check' : ''}>
+        ${option}
+      </chops-chip>
+    `;
+  }
+
+  /**
+   * Selects or unselects an option.
+   * @param {string} option The option to select or unselect.
+   * @fires Event#change
+   */
+  select(option) {
+    this.selected = {...this.selected, [option]: !this.selected[option]};
+    this.dispatchEvent(new Event('change'));
+  }
+}
+customElements.define('chops-filter-chips', ChopsFilterChips);
diff --git a/static_src/elements/chops/chops-filter-chips/chops-filter-chips.test.js b/static_src/elements/chops/chops-filter-chips/chops-filter-chips.test.js
new file mode 100644
index 0000000..3fd2671
--- /dev/null
+++ b/static_src/elements/chops/chops-filter-chips/chops-filter-chips.test.js
@@ -0,0 +1,58 @@
+// Copyright 2020 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 {assert} from 'chai';
+import sinon from 'sinon';
+import {ChopsFilterChips} from './chops-filter-chips.js';
+
+/** @type {ChopsFilterChips} */
+let element;
+
+describe('chops-filter-chips', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('chops-filter-chips');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsFilterChips);
+  });
+
+  it('renders', async () => {
+    element.options = ['one', 'two'];
+    element.selected = {two: true};
+    await element.updateComplete;
+
+    const firstChip = element.shadowRoot.firstElementChild;
+    assert.deepEqual(firstChip.className, '');
+    assert.deepEqual(firstChip.thumbnail, '');
+
+    const lastChip = element.shadowRoot.lastElementChild;
+    assert.deepEqual(lastChip.className, 'selected');
+    assert.deepEqual(lastChip.thumbnail, 'check');
+  });
+
+  it('click', async () => {
+    const onChangeStub = sinon.stub();
+
+    element.options = ['one'];
+    await element.updateComplete;
+
+    element.addEventListener('change', onChangeStub);
+    element.shadowRoot.firstElementChild.click();
+
+    assert.isTrue(element.selected.one);
+    sinon.assert.calledOnce(onChangeStub);
+
+    element.shadowRoot.firstElementChild.click();
+
+    assert.isFalse(element.selected.one);
+    sinon.assert.calledTwice(onChangeStub);
+  });
+});
diff --git a/static_src/elements/chops/chops-snackbar/chops-snackbar.js b/static_src/elements/chops/chops-snackbar/chops-snackbar.js
new file mode 100644
index 0000000..aea71b8
--- /dev/null
+++ b/static_src/elements/chops/chops-snackbar/chops-snackbar.js
@@ -0,0 +1,63 @@
+// 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';
+
+
+/**
+ * `<chops-snackbar>`
+ *
+ * A container for showing messages in a snackbar.
+ *
+ */
+export class ChopsSnackbar extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        align-items: center;
+        background-color: #333;
+        border-radius: 6px;
+        bottom: 1em;
+        left: 1em;
+        color: hsla(0, 0%, 100%, .87);
+        display: flex;
+        font-size: var(--chops-large-font-size);
+        padding: 16px;
+        position: fixed;
+        z-index: 1000;
+      }
+      button {
+        background: none;
+        border: none;
+        color: inherit;
+        cursor: pointer;
+        margin: 0;
+        margin-left: 8px;
+        padding: 0;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <slot></slot>
+      <button @click=${this.close}>
+        <i class="material-icons">close</i>
+      </button>
+    `;
+  }
+
+  /**
+   * Closes the snackbar.
+   * @fires CustomEvent#close
+   */
+  close() {
+    this.dispatchEvent(new CustomEvent('close'));
+  }
+}
+
+customElements.define('chops-snackbar', ChopsSnackbar);
diff --git a/static_src/elements/chops/chops-snackbar/chops-snackbar.test.js b/static_src/elements/chops/chops-snackbar/chops-snackbar.test.js
new file mode 100644
index 0000000..fa45d68
--- /dev/null
+++ b/static_src/elements/chops/chops-snackbar/chops-snackbar.test.js
@@ -0,0 +1,36 @@
+// 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 {assert} from 'chai';
+import sinon from 'sinon';
+import {ChopsSnackbar} from './chops-snackbar.js';
+
+let element;
+
+describe('chops-snackbar', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-snackbar');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsSnackbar);
+  });
+
+  it('dispatches close event on close click', async () => {
+    element.opened = true;
+    await element.updateComplete;
+
+    const listener = sinon.stub();
+    element.addEventListener('close', listener);
+
+    element.shadowRoot.querySelector('button').click();
+
+    sinon.assert.calledOnce(listener);
+  });
+});
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.js b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.js
new file mode 100644
index 0000000..2fa1dc2
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.js
@@ -0,0 +1,109 @@
+// 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.
+
+const DEFAULT_DATE_LOCALE = 'en-US';
+
+// Creating the datetime formatter costs ~1.5 ms, so when formatting
+// multiple timestamps, it's more performant to reuse the formatter object.
+// Export FORMATTER and SHORT_FORMATTER for testing. The return value differs
+// based on time zone and browser, so we can't use static strings for testing.
+// We can't stub out the method because it's native code and can't be modified.
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat/format#Avoid_comparing_formatted_date_values_to_static_values
+export const FORMATTER = new Intl.DateTimeFormat(DEFAULT_DATE_LOCALE, {
+  weekday: 'short',
+  year: 'numeric',
+  month: 'short',
+  day: 'numeric',
+  hour: 'numeric',
+  minute: '2-digit',
+  timeZoneName: 'short',
+});
+
+export const SHORT_FORMATTER = new Intl.DateTimeFormat(DEFAULT_DATE_LOCALE, {
+  year: 'numeric',
+  month: 'short',
+  day: 'numeric',
+});
+
+export const MS_PER_MINUTE = 60 * 1000;
+export const MS_PER_HOUR = MS_PER_MINUTE * 60;
+export const MS_PER_DAY = MS_PER_HOUR * 24;
+export const MS_PER_MONTH = MS_PER_DAY * 30;
+
+/**
+ * Helper to determine if a Date was less than a month ago.
+ * @param {Date} date The date to check.
+ * @return {boolean} Whether the date was less than a
+ *   month ago.
+ */
+function isLessThanAMonthAgo(date) {
+  const now = new Date();
+  const msDiff = Math.abs(Math.floor((now.getTime() - date.getTime())));
+  return msDiff < MS_PER_MONTH;
+}
+
+/**
+ * Displays timestamp in a standardized format to be re-used.
+ * @param {Date} date
+ * @return {string}
+ */
+export function standardTime(date) {
+  if (!date) return;
+  const absoluteTime = FORMATTER.format(date);
+
+  let timeAgoBit = '';
+  if (isLessThanAMonthAgo(date)) {
+    // Only show relative time if the time is less than a
+    // month ago because otherwise, it's not as useful.
+    timeAgoBit = ` (${relativeTime(date)})`;
+  }
+  return `${absoluteTime}${timeAgoBit}`;
+}
+
+/**
+ * Displays a timestamp in a format that's easy for a human to immediately
+ * reason about, based on long ago the time was.
+ * @param {Date} date native JavaScript Data Object.
+ * @return {string} Human-readable string of the date.
+ */
+export function relativeTime(date) {
+  if (!date) return;
+
+  const now = new Date();
+  let msDiff = now.getTime() - date.getTime();
+
+  // Use different wording depending on whether the time is in the
+  // future or past.
+  const pastOrPresentSuffix = msDiff < 0 ? 'from now' : 'ago';
+  msDiff = Math.abs(msDiff);
+
+  if (msDiff < MS_PER_MINUTE) {
+    // Less than a minute.
+    return 'just now';
+  } else if (msDiff < MS_PER_HOUR) {
+    // Less than an hour.
+    const minutes = Math.floor(msDiff / MS_PER_MINUTE);
+    if (minutes === 1) {
+      return `a minute ${pastOrPresentSuffix}`;
+    }
+    return `${minutes} minutes ${pastOrPresentSuffix}`;
+  } else if (msDiff < MS_PER_DAY) {
+    // Less than an day.
+    const hours = Math.floor(msDiff / MS_PER_HOUR);
+    if (hours === 1) {
+      return `an hour ${pastOrPresentSuffix}`;
+    }
+    return `${hours} hours ${pastOrPresentSuffix}`;
+  } else if (msDiff < MS_PER_MONTH) {
+    // Less than a month.
+    const days = Math.floor(msDiff / MS_PER_DAY);
+    if (days === 1) {
+      return `a day ${pastOrPresentSuffix}`;
+    }
+    return `${days} days ${pastOrPresentSuffix}`;
+  }
+
+  // A month or more ago. Better to show an exact date at this point.
+  return SHORT_FORMATTER.format(date);
+}
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.test.js b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.test.js
new file mode 100644
index 0000000..5fe344b
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.test.js
@@ -0,0 +1,112 @@
+// 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 {assert} from 'chai';
+import {FORMATTER, MS_PER_MONTH, standardTime,
+  relativeTime} from './chops-timestamp-helpers.js';
+import sinon from 'sinon';
+
+// The formatted date strings differ based on time zone and browser, so we can't
+// use static strings for testing. We can't stub out the format method because
+// it's native code and can't be modified. So just use the FORMATTER object.
+
+let clock;
+
+describe('chops-timestamp-helpers', () => {
+  beforeEach(() => {
+    // Set clock to the Epoch.
+    clock = sinon.useFakeTimers({
+      now: new Date(0),
+      shouldAdvanceTime: false,
+    });
+  });
+
+  afterEach(() => {
+    clock.restore();
+  });
+
+  describe('standardTime', () => {
+    it('shows relative timestamp when less than a month ago', () => {
+      const date = new Date();
+      assert.equal(standardTime(date), `${FORMATTER.format(date)} (just now)`);
+    });
+
+    it('no relative time when more than a month in the future', () => {
+      const date = new Date(1548808276 * 1000);
+      assert.equal(standardTime(date), 'Tue, Jan 29, 2019, 4:31 PM PST');
+    });
+
+    it('no relative time when more than a month in the past', () => {
+      // Jan 29, 2019, 4:31 PM PST
+      const now = 1548808276 * 1000;
+      clock.tick(now);
+
+      const date = new Date(now - MS_PER_MONTH);
+      assert.equal(standardTime(date), 'Sun, Dec 30, 2018, 4:31 PM PST');
+    });
+  });
+
+  it('relativeTime future', () => {
+    assert.equal(relativeTime(new Date()), `just now`);
+
+    assert.equal(relativeTime(new Date(59 * 1000)), `just now`);
+
+    assert.equal(relativeTime(new Date(60 * 1000)), `a minute from now`);
+    assert.equal(relativeTime(new Date(2 * 60 * 1000)),
+        `2 minutes from now`);
+    assert.equal(relativeTime(new Date(59 * 60 * 1000)),
+        `59 minutes from now`);
+
+    assert.equal(relativeTime(new Date(60 * 60 * 1000)), `an hour from now`);
+    assert.equal(relativeTime(new Date(2 * 60 * 60 * 1000)),
+        `2 hours from now`);
+    assert.equal(relativeTime(new Date(23 * 60 * 60 * 1000)),
+        `23 hours from now`);
+
+    assert.equal(relativeTime(new Date(24 * 60 * 60 * 1000)),
+        `a day from now`);
+    assert.equal(relativeTime(new Date(2 * 24 * 60 * 60 * 1000)),
+        `2 days from now`);
+    assert.equal(relativeTime(new Date(29 * 24 * 60 * 60 * 1000)),
+        `29 days from now`);
+
+    assert.equal(relativeTime(new Date(30 * 24 * 60 * 60 * 1000)),
+        'Jan 30, 1970');
+  });
+
+  it('relativeTime past', () => {
+    const baseTime = 234234 * 1000;
+
+    clock.tick(baseTime);
+
+    assert.equal(relativeTime(new Date()), `just now`);
+
+    assert.equal(relativeTime(new Date(baseTime - 59 * 1000)),
+        `just now`);
+
+    assert.equal(relativeTime(new Date(baseTime - 60 * 1000)),
+        `a minute ago`);
+    assert.equal(relativeTime(new Date(baseTime - 2 * 60 * 1000)),
+        `2 minutes ago`);
+    assert.equal(relativeTime(new Date(baseTime - 59 * 60 * 1000)),
+        `59 minutes ago`);
+
+    assert.equal(relativeTime(new Date(baseTime - 60 * 60 * 1000)),
+        `an hour ago`);
+    assert.equal(relativeTime(new Date(baseTime - 2 * 60 * 60 * 1000)),
+        `2 hours ago`);
+    assert.equal(relativeTime(new Date(baseTime - 23 * 60 * 60 * 1000)),
+        `23 hours ago`);
+
+    assert.equal(relativeTime(new Date(
+        baseTime - 24 * 60 * 60 * 1000)), `a day ago`);
+    assert.equal(relativeTime(new Date(
+        baseTime - 2 * 24 * 60 * 60 * 1000)), `2 days ago`);
+    assert.equal(relativeTime(new Date(
+        baseTime - 29 * 24 * 60 * 60 * 1000)), `29 days ago`);
+
+    assert.equal(relativeTime(new Date(
+        baseTime - 30 * 24 * 60 * 60 * 1000)), 'Dec 4, 1969');
+  });
+});
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp.js b/static_src/elements/chops/chops-timestamp/chops-timestamp.js
new file mode 100644
index 0000000..b7f157f
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp.js
@@ -0,0 +1,93 @@
+// 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} from 'lit-element';
+
+import {standardTime, relativeTime} from './chops-timestamp-helpers.js';
+
+/**
+ * `<chops-timestamp>`
+ *
+ * This element shows a time in a human readable form.
+ *
+ * @customElement
+ */
+export class ChopsTimestamp extends LitElement {
+  /** @override */
+  render() {
+    return html`
+      ${this._displayedTime}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /** The data for the time which can be in any format readable by
+       *  Date.parse.
+       */
+      timestamp: {type: String},
+      /** When true, a shorter version of the date will be displayed. */
+      short: {type: Boolean},
+      /**
+       * The Date object, which is stored in UTC, to be converted to a string.
+      */
+      _date: {type: Object},
+    };
+  }
+
+  /**
+   * @return {string} Human-readable timestamp.
+   */
+  get _displayedTime() {
+    const date = this._date;
+    const short = this.short;
+    // TODO(zhangtiff): Add logic to dynamically re-compute relative time
+    //   based on set intervals.
+    if (!date) return;
+    if (short) {
+      return relativeTime(date);
+    }
+    return standardTime(date);
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('timestamp')) {
+      this._date = this._parseTimestamp(this.timestamp);
+      this.setAttribute('title', standardTime(this._date));
+    }
+    super.update(changedProperties);
+  }
+
+  /**
+   * Turns a timestamp string into a native JavaScript Date Object.
+   * @param {string} timestamp Timestamp string in either an ISO format or
+   *   Unix timestamp format. If Unix time, the function expects the time in
+   *   seconds, not milliseconds.
+   * @return {Date}
+   */
+  _parseTimestamp(timestamp) {
+    if (!timestamp) return;
+
+    let unixTimeMs = 0;
+    // Make sure to do Date.parse before Number.parseInt because parseInt
+    // will parse numbers within a string.
+    if (/^\d+$/.test(timestamp)) {
+      // Check if a string contains only digits before guessing it's
+      // unix time. This is necessary because Number.parseInt will parse
+      // number strings that contain non-numbers.
+      unixTimeMs = Number.parseInt(timestamp) * 1000;
+    } else {
+      // Date.parse will parse strings with only numbers as though those
+      // strings were truncated ISO formatted strings.
+      unixTimeMs = Date.parse(timestamp);
+      if (Number.isNaN(unixTimeMs)) {
+        throw new Error('Timestamp is in an invalid format.');
+      }
+    }
+    return new Date(unixTimeMs);
+  }
+}
+customElements.define('chops-timestamp', ChopsTimestamp);
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp.test.js b/static_src/elements/chops/chops-timestamp/chops-timestamp.test.js
new file mode 100644
index 0000000..21c227d
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp.test.js
@@ -0,0 +1,88 @@
+// 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 {assert, expect} from 'chai';
+import {ChopsTimestamp} from './chops-timestamp.js';
+import {FORMATTER, SHORT_FORMATTER} from './chops-timestamp-helpers.js';
+import sinon from 'sinon';
+
+// The formatted date strings differ based on time zone and browser, so we can't
+// use static strings for testing. We can't stub out the format method because
+// it's native code and can't be modified. So just use the FORMATTER object.
+
+let element;
+let clock;
+
+describe('chops-timestamp', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-timestamp');
+    document.body.appendChild(element);
+
+    // Set clock to the Epoch.
+    clock = sinon.useFakeTimers({
+      now: new Date(0),
+      shouldAdvanceTime: false,
+    });
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    clock.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsTimestamp);
+  });
+
+  it('changing timestamp changes date', async () => {
+    const timestamp = 1548808276;
+    element.timestamp = String(timestamp);
+
+    await element.updateComplete;
+
+    assert.include(element.shadowRoot.textContent,
+        FORMATTER.format(new Date(timestamp * 1000)));
+  });
+
+  it('parses ISO dates', async () => {
+    const timestamp = '2016-11-11';
+    element.timestamp = timestamp;
+
+    await element.updateComplete;
+
+    assert.include(element.shadowRoot.textContent,
+        FORMATTER.format(new Date(timestamp)));
+  });
+
+  it('invalid timestamp format', () => {
+    expect(() => {
+      element._parseTimestamp('random string');
+    }).to.throw('Timestamp is in an invalid format.');
+  });
+
+  it('short time renders shorter time', async () => {
+    element.short = true;
+    element.timestamp = '5';
+
+    await element.updateComplete;
+
+    assert.include(element.shadowRoot.textContent,
+        `just now`);
+
+    element.timestamp = '60';
+
+    await element.updateComplete;
+
+    assert.include(element.shadowRoot.textContent,
+        `a minute from now`);
+
+    const timestamp = 1548808276;
+    element.timestamp = String(timestamp);
+
+    await element.updateComplete;
+
+    assert.include(element.shadowRoot.textContent,
+        SHORT_FORMATTER.format(timestamp * 1000));
+  });
+});
diff --git a/static_src/elements/chops/chops-toggle/chops-toggle.js b/static_src/elements/chops/chops-toggle/chops-toggle.js
new file mode 100644
index 0000000..52868bd
--- /dev/null
+++ b/static_src/elements/chops/chops-toggle/chops-toggle.js
@@ -0,0 +1,124 @@
+// 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';
+
+/**
+ * `<chops-toggle>`
+ *
+ * A toggle button component. This component is primarily a wrapper
+ * around a native checkbox to allow easy sharing of styles.
+ *
+ */
+export class ChopsToggle extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        --chops-toggle-bg: none;
+        --chops-toggle-color: var(--chops-primary-font-color);
+        --chops-toggle-hover-bg: rgba(0, 0, 0, 0.3);
+        --chops-toggle-focus-border: hsl(193, 82%, 63%);
+        --chops-toggle-checked-bg: rgba(0, 0, 0, 0.6);
+        --chops-toggle-checked-color: var(--chops-white);
+      }
+      label {
+        background: var(--chops-toggle-bg);
+        color: var(--chops-toggle-color);
+        cursor: pointer;
+        align-items: center;
+        padding: 2px 4px;
+        border: var(--chops-normal-border);
+        border-radius: var(--chops-button-radius);
+      }
+      input[type="checkbox"] {
+        /* We need the checkbox to be hidden but still accessible. */
+        opacity: 0;
+        width: 0;
+        height: 0;
+        position: absolute;
+        top: -9999;
+        left: -9999;
+      }
+      input[type="checkbox"]:focus + label {
+        /* Make sure an outline shows around this element for
+        * accessibility.
+        */
+        box-shadow: 0 0 5px 1px var(--chops-toggle-focus-border);
+      }
+      input[type="checkbox"]:hover + label {
+        background: var(--chops-toggle-hover-bg);
+      }
+      input[type="checkbox"]:checked + label {
+        background: var(--chops-toggle-checked-bg);
+        color: var(--chops-toggle-checked-color);
+      }
+      input[type="checkbox"]:disabled + label {
+        opacity: 0.8;
+        cursor: default;
+        pointer-events: none;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <input id="checkbox"
+        type="checkbox"
+        ?checked=${this.checked}
+        ?disabled=${this.disabled}
+        @change=${this._checkedChangeHandler}
+      >
+      <label for="checkbox">
+        <slot></slot>
+      </label>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Note: At the moment, this component does not manage its own
+       * internal checked state. It expects its checked state to come
+       * from its parent, and its parent is expected to update the
+       * chops-checkbox's checked state on a change event.
+       *
+       * This can be generalized in the future to support multiple
+       * ways of managing checked state if needed.
+       **/
+      checked: {type: Boolean},
+      /**
+       * Whether the element currently allows checking or not.
+       */
+      disabled: {type: Boolean},
+    };
+  }
+
+  click() {
+    super.click();
+    this.shadowRoot.querySelector('#checkbox').click();
+  }
+
+  _checkedChangeHandler(evt) {
+    this._checkedChange(evt.target.checked);
+  }
+
+  /**
+   * @param {boolean} checked
+   * @fires CustomEvent#checked-change
+   * @private
+   */
+  _checkedChange(checked) {
+    if (checked === this.checked) return;
+    const customEvent = new CustomEvent('checked-change', {
+      detail: {
+        checked: checked,
+      },
+    });
+    this.dispatchEvent(customEvent);
+  }
+}
+customElements.define('chops-toggle', ChopsToggle);
diff --git a/static_src/elements/chops/chops-toggle/chops-toggle.test.js b/static_src/elements/chops/chops-toggle/chops-toggle.test.js
new file mode 100644
index 0000000..423c993
--- /dev/null
+++ b/static_src/elements/chops/chops-toggle/chops-toggle.test.js
@@ -0,0 +1,45 @@
+// 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 {assert} from 'chai';
+import sinon from 'sinon';
+import {ChopsToggle} from './chops-toggle.js';
+
+let element;
+
+describe('chops-toggle', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-toggle');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsToggle);
+  });
+
+  it('clicking toggle dispatches checked-change event', async () => {
+    element.checked = false;
+    sinon.stub(window, 'CustomEvent');
+    sinon.stub(element, 'dispatchEvent');
+
+    await element.updateComplete;
+
+    element.click();
+
+    assert.deepEqual(window.CustomEvent.args[0][0], 'checked-change');
+    assert.deepEqual(window.CustomEvent.args[0][1], {
+      detail: {checked: true},
+    });
+
+    assert.isTrue(window.CustomEvent.calledOnce);
+    assert.isTrue(element.dispatchEvent.calledOnce);
+
+    window.CustomEvent.restore();
+    element.dispatchEvent.restore();
+  });
+});