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();
+ });
+});