Convert gr-change-list to lit

Change-Id: I47d7e148f1f822ac91b1d4c404270ec27036fd41
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 0621cc2..898de14 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -97,7 +97,6 @@
     "elements/admin/gr-permission/gr-permission_html.ts",
     "elements/admin/gr-repo-access/gr-repo-access_html.ts",
     "elements/admin/gr-rule-editor/gr-rule-editor_html.ts",
-    "elements/change-list/gr-change-list/gr-change-list_html.ts",
     "elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts",
     "elements/change/gr-change-actions/gr-change-actions_html.ts",
     "elements/change/gr-change-metadata/gr-change-metadata_html.ts",
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 2167d31..142abaa 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -189,15 +189,12 @@
           .preferences=${this.preferences}
           .selectedIndex=${this.viewState.selectedChangeIndex}
           .showStar=${loggedIn}
-          @selected-index-changed=${(
-            e: ValueChangedEvent<ChangeListViewState>
-          ) => {
+          @selected-index-changed=${(e: ValueChangedEvent<number>) => {
             this.handleSelectedIndexChanged(e);
           }}
           @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
             this.handleToggleStar(e);
           }}
-          .observerTarget=${this}
         ></gr-change-list>
         ${this.renderChangeListViewNav()}
       </div>
@@ -416,11 +413,9 @@
     );
   }
 
-  private handleSelectedIndexChanged(
-    e: ValueChangedEvent<ChangeListViewState>
-  ) {
+  private handleSelectedIndexChanged(e: ValueChangedEvent<number>) {
     if (!this.viewState) return;
-    this.viewState.selectedChangeIndex = Number(e.detail.value);
+    this.viewState.selectedChangeIndex = e.detail.value;
     fire(this, 'view-state-changed', {value: this.viewState});
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 7f9a9fd..1185fe8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -15,31 +15,19 @@
  * limitations under the License.
  */
 
-import '../../../styles/gr-change-list-styles';
-import '../../../styles/gr-font-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
 import '../gr-change-list-item/gr-change-list-item';
+import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-list_html';
 import {getAppContext} from '../../../services/app-context';
 import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {
   GerritNav,
-  DashboardSection,
   YOUR_TURN,
   CLOSED,
 } from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {isOwner} from '../../../utils/change-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {
   AccountInfo,
@@ -48,15 +36,22 @@
   PreferencesInput,
 } from '../../../types/common';
 import {hasAttention} from '../../../utils/attention-set-util';
-import {fireEvent, fireReload} from '../../../utils/event-util';
+import {fire, fireEvent, fireReload} from '../../../utils/event-util';
 import {ScrollMode} from '../../../constants/constants';
-import {listen} from '../../../services/shortcuts/shortcuts-service';
 import {
   getRequirements,
   showNewSubmitRequirements,
 } from '../../../utils/label-util';
 import {addGlobalShortcut, Key} from '../../../utils/dom-util';
 import {unique} from '../../../utils/common-util';
+import {changeListStyles} from '../../../styles/gr-change-list-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {queryAll} from '../../../utils/common-util';
 import {ValueChangedEvent} from '../../../types/events';
 
 const NUMBER_FIXED_COLUMNS = 3;
@@ -79,24 +74,15 @@
 ];
 
 export interface ChangeListSection {
+  countLabel?: string;
+  isOutgoing?: boolean;
   name?: string;
   query?: string;
   results: ChangeInfo[];
 }
 
-export interface GrChangeList {
-  $: {};
-}
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-change-list')
-export class GrChangeList extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrChangeList extends LitElement {
   /**
    * Fired when next page key shortcut was pressed.
    *
@@ -116,7 +102,7 @@
   @property({type: Object})
   account: AccountInfo | undefined = undefined;
 
-  @property({type: Array, observer: '_changesChanged'})
+  @property({type: Array})
   changes?: ChangeInfo[];
 
   /**
@@ -126,13 +112,9 @@
   @property({type: Array})
   sections: ChangeListSection[] = [];
 
-  @property({type: Array, computed: '_computeLabelNames(sections)'})
-  labelNames?: string[];
+  @state() private dynamicHeaderEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicHeaderEndpoints?: string[];
-
-  @property({type: Number, notify: true})
+  @property({type: Number})
   selectedIndex?: number;
 
   @property({type: Boolean})
@@ -156,24 +138,14 @@
   @property({type: Boolean})
   isCursorMoving = false;
 
-  @property({type: Object})
-  _config?: ServerInfo;
+  // private but used in test
+  @state() config?: ServerInfo;
 
   private readonly flagsService = getAppContext().flagsService;
 
   private readonly restApiService = getAppContext().restApiService;
 
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [
-      listen(Shortcut.CURSOR_NEXT_CHANGE, _ => this._nextChange()),
-      listen(Shortcut.CURSOR_PREV_CHANGE, _ => this._prevChange()),
-      listen(Shortcut.NEXT_PAGE, _ => this._nextPage()),
-      listen(Shortcut.PREV_PAGE, _ => this._prevPage()),
-      listen(Shortcut.OPEN_CHANGE, _ => this.openChange()),
-      listen(Shortcut.TOGGLE_CHANGE_STAR, _ => this._toggleChangeStar()),
-      listen(Shortcut.REFRESH_CHANGE_LIST, _ => this._refreshChangeList()),
-    ];
-  }
+  private readonly shortcuts = new ShortcutController(this);
 
   private cursor = new GrCursorManager();
 
@@ -181,22 +153,33 @@
     super();
     this.cursor.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.cursor.focusOnMove = true;
+    this.shortcuts.addAbstract(Shortcut.CURSOR_NEXT_CHANGE, () =>
+      this.nextChange()
+    );
+    this.shortcuts.addAbstract(Shortcut.CURSOR_PREV_CHANGE, () =>
+      this.prevChange()
+    );
+    this.shortcuts.addAbstract(Shortcut.NEXT_PAGE, () => this.nextPage());
+    this.shortcuts.addAbstract(Shortcut.PREV_PAGE, () => this.prevPage());
+    this.shortcuts.addAbstract(Shortcut.OPEN_CHANGE, () => this.openChange());
+    this.shortcuts.addAbstract(Shortcut.TOGGLE_CHANGE_STAR, () =>
+      this.toggleChangeStar()
+    );
+    this.shortcuts.addAbstract(Shortcut.REFRESH_CHANGE_LIST, () =>
+      this.refreshChangeList()
+    );
     addGlobalShortcut({key: Key.ENTER}, () => this.openChange());
   }
 
-  override ready() {
-    super.ready();
-    this.restApiService.getConfig().then(config => {
-      this._config = config;
-    });
-  }
-
   override connectedCallback() {
     super.connectedCallback();
+    this.restApiService.getConfig().then(config => {
+      this.config = config;
+    });
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._dynamicHeaderEndpoints =
+        this.dynamicHeaderEndpoints =
           getPluginEndpoints().getDynamicEndpoints('change-list-header');
       });
   }
@@ -206,37 +189,259 @@
     super.disconnectedCallback();
   }
 
-  _lowerCase(column: string) {
-    return column.toLowerCase();
+  static override get styles() {
+    return [
+      changeListStyles,
+      fontStyles,
+      sharedStyles,
+      css`
+        #changeList {
+          border-collapse: collapse;
+          width: 100%;
+        }
+        .section-count-label {
+          color: var(--deemphasized-text-color);
+          font-family: var(--font-family);
+          font-size: var(--font-size-small);
+          font-weight: var(--font-weight-normal);
+          line-height: var(--line-height-small);
+        }
+        a.section-title:hover {
+          text-decoration: none;
+        }
+        a.section-title:hover .section-count-label {
+          text-decoration: none;
+        }
+        a.section-title:hover .section-name {
+          text-decoration: underline;
+        }
+      `,
+    ];
   }
 
-  @observe('account', 'preferences', '_config', 'sections')
-  _computePreferences(
-    account?: AccountInfo,
-    preferences?: PreferencesInput,
-    config?: ServerInfo,
-    sections?: ChangeListSection[]
+  override render() {
+    const labelNames = this.computeLabelNames(this.sections);
+    return html`
+      <table id="changeList">
+        ${this.sections.map((changeSection, sectionIndex) =>
+          this.renderSections(changeSection, sectionIndex, labelNames)
+        )}
+      </table>
+    `;
+  }
+
+  private renderSections(
+    changeSection: ChangeListSection,
+    sectionIndex: number,
+    labelNames: string[]
   ) {
-    if (!config) {
-      return;
+    return html`
+      ${this.renderSectionHeader(changeSection, labelNames)}
+      <tbody class="groupContent">
+        ${this.isEmpty(changeSection)
+          ? this.renderNoChangesRow(changeSection, labelNames)
+          : this.renderColumnHeaders(changeSection, labelNames)}
+        ${changeSection.results.map((change, index) =>
+          this.renderChangeRow(
+            changeSection,
+            change,
+            index,
+            sectionIndex,
+            labelNames
+          )
+        )}
+      </tbody>
+    `;
+  }
+
+  private renderSectionHeader(
+    changeSection: ChangeListSection,
+    labelNames: string[]
+  ) {
+    if (!changeSection.name) return;
+
+    return html`
+      <tbody>
+        <tr class="groupHeader">
+          <td aria-hidden="true" class="leftPadding"></td>
+          <td aria-hidden="true" class="star" ?hidden=${!this.showStar}></td>
+          <td
+            class="cell"
+            colspan="${this.computeColspan(changeSection, labelNames)}"
+          >
+            <h2 class="heading-3">
+              <a
+                href="${this.sectionHref(changeSection.query)}"
+                class="section-title"
+              >
+                <span class="section-name">${changeSection.name}</span>
+                <span class="section-count-label"
+                  >${changeSection.countLabel}</span
+                >
+              </a>
+            </h2>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+
+  private renderNoChangesRow(
+    changeSection: ChangeListSection,
+    labelNames: string[]
+  ) {
+    return html`
+      <tr class="noChanges">
+        <td class="leftPadding" ?aria-hidden="true"></td>
+        <td
+          class="star"
+          ?aria-hidden=${!this.showStar}
+          ?hidden=${!this.showStar}
+        ></td>
+        <td
+          class="cell"
+          colspan="${this.computeColspan(changeSection, labelNames)}"
+        >
+          ${this.getSpecialEmptySlot(changeSection)
+            ? html`<slot
+                name="${this.getSpecialEmptySlot(changeSection)}"
+              ></slot>`
+            : 'No changes'}
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderColumnHeaders(
+    changeSection: ChangeListSection,
+    labelNames: string[]
+  ) {
+    return html`
+      <tr class="groupTitle">
+        <td class="leftPadding" ?aria-hidden="true"></td>
+        <td
+          class="star"
+          aria-label="Star status column"
+          ?hidden=${!this.showStar}
+        ></td>
+        <td class="number" ?hidden=${!this.showNumber}>#</td>
+        ${this.computeColumns(changeSection).map(item =>
+          this.renderHeaderCell(item)
+        )}
+        ${labelNames?.map(labelName => this.renderLabelHeader(labelName))}
+        ${this.dynamicHeaderEndpoints?.map(pluginHeader =>
+          this.renderEndpointHeader(pluginHeader)
+        )}
+      </tr>
+    `;
+  }
+
+  private renderHeaderCell(item: string) {
+    return html`<td class="${item.toLowerCase()}">${item}</td>`;
+  }
+
+  private renderLabelHeader(labelName: string) {
+    return html`
+      <td class="label" title="${labelName}">
+        ${this.computeLabelShortcut(labelName)}
+      </td>
+    `;
+  }
+
+  private renderEndpointHeader(pluginHeader: string) {
+    return html`
+      <td class="endpoint">
+        <gr-endpoint-decorator .name="${pluginHeader}"></gr-endpoint-decorator>
+      </td>
+    `;
+  }
+
+  private renderChangeRow(
+    changeSection: ChangeListSection,
+    change: ChangeInfo,
+    index: number,
+    sectionIndex: number,
+    labelNames: string[]
+  ) {
+    const ariaLabel = this.computeAriaLabel(change, changeSection.name);
+    const highlight = this.computeItemHighlight(
+      this.account,
+      change,
+      changeSection.name
+    );
+    const selected = this.computeItemSelected(
+      sectionIndex,
+      index,
+      this.selectedIndex
+    );
+    const tabindex = this.computeTabIndex(
+      sectionIndex,
+      index,
+      this.isCursorMoving,
+      this.selectedIndex
+    );
+    const visibleChangeTableColumns = this.computeColumns(changeSection);
+    return html`
+      <gr-change-list-item
+        .account=${this.account}
+        ?selected=${selected}
+        .highlight=${highlight}
+        .change=${change}
+        .config=${this.config}
+        .sectionName=${changeSection.name}
+        .visibleChangeTableColumns=${visibleChangeTableColumns}
+        .showNumber=${this.showNumber}
+        .showStar=${this.showStar}
+        ?tabindex=${tabindex}
+        .labelNames=${labelNames}
+        aria-label=${ariaLabel}
+      ></gr-change-list-item>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (
+      changedProperties.has('account') ||
+      changedProperties.has('preferences') ||
+      changedProperties.has('config') ||
+      changedProperties.has('sections')
+    ) {
+      this.computePreferences();
     }
 
-    const changes = (sections ?? []).map(section => section.results).flat();
+    if (changedProperties.has('changes')) {
+      this.changesChanged();
+    }
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('sections')) {
+      this.sectionsChanged();
+    }
+  }
+
+  private computePreferences() {
+    if (!this.config) return;
+
+    const changes = (this.sections ?? [])
+      .map(section => section.results)
+      .flat();
     this.changeTableColumns = columnNames;
     this.showNumber = false;
     this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
-      this._isColumnEnabled(col, config, changes)
+      this._isColumnEnabled(col, this.config, changes)
     );
-    if (account && preferences) {
-      this.showNumber = !!(
-        preferences && preferences.legacycid_in_change_table
-      );
-      if (preferences.change_table && preferences.change_table.length > 0) {
-        const prefColumns = preferences.change_table.map(column =>
+    if (this.account && this.preferences) {
+      this.showNumber = !!this.preferences?.legacycid_in_change_table;
+      if (
+        this.preferences?.change_table &&
+        this.preferences.change_table.length > 0
+      ) {
+        const prefColumns = this.preferences.change_table.map(column =>
           column === 'Project' ? 'Repo' : column
         );
         this.visibleChangeTableColumns = prefColumns.filter(col =>
-          this._isColumnEnabled(col, config, changes)
+          this._isColumnEnabled(col, this.config, changes)
         );
       }
     }
@@ -271,12 +476,9 @@
    *
    * @param visibleColumns are the columns according to configs and user prefs
    */
-  _computeColumns(
-    section?: ChangeListSection,
-    visibleColumns?: string[]
-  ): string[] {
-    if (!section || !visibleColumns) return [];
-    const cols = [...visibleColumns];
+  private computeColumns(section?: ChangeListSection): string[] {
+    if (!section || !this.visibleChangeTableColumns) return [];
+    const cols = [...this.visibleChangeTableColumns];
     const updatedIndex = cols.indexOf('Updated');
     if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
       cols[updatedIndex] = 'Waiting';
@@ -287,20 +489,16 @@
     return cols;
   }
 
-  _computeColspan(
-    section?: ChangeListSection,
-    visibleColumns?: string[],
-    labelNames?: string[]
-  ) {
-    const cols = this._computeColumns(section, visibleColumns);
+  // private but used in test
+  computeColspan(section?: ChangeListSection, labelNames?: string[]) {
+    const cols = this.computeColumns(section);
     if (!cols || !labelNames) return 1;
     return cols.length + labelNames.length + NUMBER_FIXED_COLUMNS;
   }
 
-  _computeLabelNames(sections: ChangeListSection[]) {
-    if (!sections) {
-      return [];
-    }
+  // private but used in test
+  computeLabelNames(sections: ChangeListSection[]) {
+    if (!sections) return [];
     let labels: string[] = [];
     const nonExistingLabel = function (item: string) {
       return !labels.includes(item);
@@ -332,7 +530,8 @@
     return labels.sort();
   }
 
-  _computeLabelShortcut(labelName: string) {
+  // private but used in test
+  computeLabelShortcut(labelName: string) {
     if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
       labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
     }
@@ -347,11 +546,12 @@
       .slice(0, MAX_SHORTCUT_CHARS);
   }
 
-  _changesChanged(changes: ChangeInfo[]) {
-    this.sections = changes ? [{results: changes}] : [];
+  private changesChanged() {
+    this.sections = this.changes ? [{results: this.changes}] : [];
   }
 
-  _processQuery(query: string) {
+  // private but used in test
+  processQuery(query: string) {
     let tokens = query.split(' ');
     const invalidTokens = ['limit:', 'age:', '-age:'];
     tokens = tokens.filter(
@@ -361,19 +561,22 @@
     return tokens.join(' ');
   }
 
-  _sectionHref(query: string) {
-    return GerritNav.getUrlForSearchQuery(this._processQuery(query));
+  private sectionHref(query?: string) {
+    if (!query) return;
+    return GerritNav.getUrlForSearchQuery(this.processQuery(query));
   }
 
   /**
    * Maps an index local to a particular section to the absolute index
    * across all the changes on the page.
    *
+   * private but used in test
+   *
    * @param sectionIndex index of section
    * @param localIndex index of row within section
    * @return absolute index of row in the aggregate dashboard
    */
-  _computeItemAbsoluteIndex(sectionIndex: number, localIndex: number) {
+  computeItemAbsoluteIndex(sectionIndex: number, localIndex: number) {
     let idx = 0;
     for (let i = 0; i < sectionIndex; i++) {
       idx += this.sections[i].results.length;
@@ -381,28 +584,28 @@
     return idx + localIndex;
   }
 
-  _computeItemSelected(
+  private computeItemSelected(
     sectionIndex: number,
     index: number,
-    selectedIndex: number
+    selectedIndex?: number
   ) {
-    const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
+    const idx = this.computeItemAbsoluteIndex(sectionIndex, index);
     return idx === selectedIndex;
   }
 
-  _computeTabIndex(
+  private computeTabIndex(
     sectionIndex: number,
     index: number,
-    selectedIndex: number,
-    isCursorMoving: boolean
+    isCursorMoving: boolean,
+    selectedIndex?: number
   ) {
     if (isCursorMoving) return 0;
-    return this._computeItemSelected(sectionIndex, index, selectedIndex)
+    return this.computeItemSelected(sectionIndex, index, selectedIndex)
       ? 0
       : undefined;
   }
 
-  _computeItemHighlight(
+  private computeItemHighlight(
     account?: AccountInfo,
     change?: ChangeInfo,
     sectionName?: string
@@ -416,48 +619,45 @@
     );
   }
 
-  _nextChange() {
+  private nextChange() {
     this.isCursorMoving = true;
     this.cursor.next();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
+    fire(this, 'selected-index-changed', {value: this.cursor.index});
   }
 
-  _prevChange() {
+  private prevChange() {
     this.isCursorMoving = true;
     this.cursor.previous();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
+    fire(this, 'selected-index-changed', {value: this.cursor.index});
   }
 
-  openChange() {
-    const change = this._changeForIndex(this.selectedIndex);
+  private openChange() {
+    const change = this.changeForIndex(this.selectedIndex);
     if (change) GerritNav.navigateToChange(change);
   }
 
-  _nextPage() {
+  private nextPage() {
     fireEvent(this, 'next-page');
   }
 
-  _prevPage() {
-    this.dispatchEvent(
-      new CustomEvent('previous-page', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+  private prevPage() {
+    fireEvent(this, 'previous-page');
   }
 
-  _refreshChangeList() {
+  private refreshChangeList() {
     fireReload(this);
   }
 
-  _toggleChangeStar() {
-    this._toggleStarForIndex(this.selectedIndex);
+  private toggleChangeStar() {
+    this.toggleStarForIndex(this.selectedIndex);
   }
 
-  _toggleStarForIndex(index?: number) {
-    const changeEls = this._getListItems();
+  private toggleStarForIndex(index?: number) {
+    const changeEls = this.getListItems();
     if (index === undefined || index >= changeEls.length || !changeEls[index]) {
       return;
     }
@@ -467,40 +667,38 @@
     if (grChangeStar) grChangeStar.toggleStar();
   }
 
-  _changeForIndex(index?: number) {
-    const changeEls = this._getListItems();
+  private changeForIndex(index?: number) {
+    const changeEls = this.getListItems();
     if (index !== undefined && index < changeEls.length && changeEls[index]) {
       return changeEls[index].change;
     }
     return null;
   }
 
-  _getListItems() {
-    const items = this.root?.querySelectorAll('gr-change-list-item');
+  private getListItems() {
+    const items = queryAll<GrChangeListItem>(this, 'gr-change-list-item');
     return !items ? [] : Array.from(items);
   }
 
-  @observe('sections.*')
-  _sectionsChanged() {
-    // Flush DOM operations so that the list item elements will be loaded.
-    afterNextRender(this, () => {
-      this.cursor.stops = this._getListItems();
-      this.cursor.moveToStart();
-      if (this.selectedIndex) this.cursor.setCursorAtIndex(this.selectedIndex);
-    });
+  private sectionsChanged() {
+    this.cursor.stops = this.getListItems();
+    this.cursor.moveToStart();
+    if (this.selectedIndex) this.cursor.setCursorAtIndex(this.selectedIndex);
   }
 
-  _getSpecialEmptySlot(section: DashboardSection) {
+  // private but used in test
+  getSpecialEmptySlot(section: ChangeListSection) {
     if (section.isOutgoing) return 'empty-outgoing';
     if (section.name === YOUR_TURN.name) return 'empty-your-turn';
     return '';
   }
 
-  _isEmpty(section: DashboardSection) {
+  // private but used in test
+  isEmpty(section: ChangeListSection) {
     return !section.results?.length;
   }
 
-  _computeAriaLabel(change?: ChangeInfo, sectionName?: string) {
+  private computeAriaLabel(change?: ChangeInfo, sectionName?: string) {
     if (!change) return '';
     return change.subject + (sectionName ? `, section: ${sectionName}` : '');
   }
@@ -508,7 +706,7 @@
 
 declare global {
   interface HTMLElementEventMap {
-    'selected-index-changed': ValueChangedEvent;
+    'selected-index-changed': ValueChangedEvent<number>;
   }
   interface HTMLElementTagNameMap {
     'gr-change-list': GrChangeList;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
deleted file mode 100644
index 77320b9..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-change-list-styles">
-    #changeList {
-      border-collapse: collapse;
-      width: 100%;
-    }
-    .section-count-label {
-      color: var(--deemphasized-text-color);
-      font-family: var(--font-family);
-      font-size: var(--font-size-small);
-      font-weight: var(--font-weight-normal);
-      line-height: var(--line-height-small);
-    }
-    a.section-title:hover {
-      text-decoration: none;
-    }
-    a.section-title:hover .section-count-label {
-      text-decoration: none;
-    }
-    a.section-title:hover .section-name {
-      text-decoration: underline;
-    }
-  </style>
-  <table id="changeList">
-    <template
-      is="dom-repeat"
-      items="[[sections]]"
-      as="changeSection"
-      index-as="sectionIndex"
-    >
-      <template is="dom-if" if="[[changeSection.name]]">
-        <tbody>
-          <tr class="groupHeader">
-            <td aria-hidden="true" class="leftPadding"></td>
-            <td
-              aria-hidden="true"
-              class="star"
-              hidden$="[[!showStar]]"
-              hidden=""
-            ></td>
-            <td
-              class="cell"
-              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
-            >
-              <h2 class="heading-3">
-                <a
-                  href$="[[_sectionHref(changeSection.query)]]"
-                  class="section-title"
-                >
-                  <span class="section-name">[[changeSection.name]]</span>
-                  <span class="section-count-label"
-                    >[[changeSection.countLabel]]</span
-                  >
-                </a>
-              </h2>
-            </td>
-          </tr>
-        </tbody>
-      </template>
-      <tbody class="groupContent">
-        <template is="dom-if" if="[[_isEmpty(changeSection)]]">
-          <tr class="noChanges">
-            <td aria-hidden="true" class="leftPadding"></td>
-            <td
-              aria-hidden="[[!showStar]]"
-              class="star"
-              hidden$="[[!showStar]]"
-            ></td>
-            <td
-              class="cell"
-              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
-            >
-              <template
-                is="dom-if"
-                if="[[_getSpecialEmptySlot(changeSection)]]"
-              >
-                <slot name="[[_getSpecialEmptySlot(changeSection)]]"></slot>
-              </template>
-              <template
-                is="dom-if"
-                if="[[!_getSpecialEmptySlot(changeSection)]]"
-              >
-                No changes
-              </template>
-            </td>
-          </tr>
-        </template>
-        <template is="dom-if" if="[[!_isEmpty(changeSection)]]">
-          <tr class="groupTitle">
-            <td aria-hidden="true" class="leftPadding"></td>
-            <td
-              aria-label="Star status column"
-              class="star"
-              hidden$="[[!showStar]]"
-              hidden=""
-            ></td>
-            <td class="number" hidden$="[[!showNumber]]" hidden="">#</td>
-            <template
-              is="dom-repeat"
-              items="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
-              as="item"
-            >
-              <td class$="[[_lowerCase(item)]]">[[item]]</td>
-            </template>
-            <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-              <td class="label" title$="[[labelName]]">
-                [[_computeLabelShortcut(labelName)]]
-              </td>
-            </template>
-            <template
-              is="dom-repeat"
-              items="[[_dynamicHeaderEndpoints]]"
-              as="pluginHeader"
-            >
-              <td class="endpoint">
-                <gr-endpoint-decorator name$="[[pluginHeader]]">
-                </gr-endpoint-decorator>
-              </td>
-            </template>
-          </tr>
-        </template>
-        <template is="dom-repeat" items="[[changeSection.results]]" as="change">
-          <gr-change-list-item
-            account="[[account]]"
-            selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
-            highlight$="[[_computeItemHighlight(account, change, changeSection.name)]]"
-            change="[[change]]"
-            config="[[_config]]"
-            section-name="[[changeSection.name]]"
-            visible-change-table-columns="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
-            show-number="[[showNumber]]"
-            show-star="[[showStar]]"
-            tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex, isCursorMoving)]]"
-            label-names="[[labelNames]]"
-            aria-label$="[[_computeAriaLabel(change, changeSection.name)]]"
-          ></gr-change-list-item>
-        </template>
-      </tbody>
-    </template>
-  </table>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index ee15b44..50708c0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -17,10 +17,8 @@
 import '../../../test/common-test-setup-karma';
 import './gr-change-list';
 import {GrChangeList} from './gr-change-list';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
-  mockPromise,
   pressKey,
   query,
   queryAll,
@@ -47,11 +45,12 @@
   });
 
   suite('test show change number not logged in', () => {
-    setup(() => {
+    setup(async () => {
       element = basicFixture.instantiate();
       element.account = undefined;
       element.preferences = undefined;
-      element._config = createServerInfo();
+      element.config = createServerInfo();
+      await element.updateComplete;
     });
 
     test('show number disabled', () => {
@@ -60,7 +59,7 @@
   });
 
   suite('test show change number preference enabled', () => {
-    setup(() => {
+    setup(async () => {
       element = basicFixture.instantiate();
       element.preferences = {
         legacycid_in_change_table: true,
@@ -68,8 +67,8 @@
         change_table: [],
       };
       element.account = {_account_id: 1001 as AccountId};
-      element._config = createServerInfo();
-      flush();
+      element.config = createServerInfo();
+      await element.updateComplete;
     });
 
     test('show number enabled', () => {
@@ -78,7 +77,7 @@
   });
 
   suite('test show change number preference disabled', () => {
-    setup(() => {
+    setup(async () => {
       element = basicFixture.instantiate();
       // legacycid_in_change_table is not set when false.
       element.preferences = {
@@ -86,8 +85,8 @@
         change_table: [],
       };
       element.account = {_account_id: 1001 as AccountId};
-      element._config = createServerInfo();
-      flush();
+      element.config = createServerInfo();
+      await element.updateComplete;
     });
 
     test('show number disabled', () => {
@@ -97,7 +96,7 @@
 
   test('computed fields', () => {
     assert.equal(
-      element._computeLabelNames([
+      element.computeLabelNames([
         {
           results: [
             {...createChange(), _number: 0 as NumericChangeId, labels: {}},
@@ -107,7 +106,7 @@
       0
     );
     assert.equal(
-      element._computeLabelNames([
+      element.computeLabelNames([
         {
           results: [
             {
@@ -137,49 +136,43 @@
       3
     );
 
-    assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
-    assert.equal(element._computeLabelShortcut('Verified'), 'V');
-    assert.equal(element._computeLabelShortcut('Library-Compliance'), 'LC');
-    assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
-    assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
+    assert.equal(element.computeLabelShortcut('Code-Review'), 'CR');
+    assert.equal(element.computeLabelShortcut('Verified'), 'V');
+    assert.equal(element.computeLabelShortcut('Library-Compliance'), 'LC');
+    assert.equal(element.computeLabelShortcut('PolyGerrit-Review'), 'PR');
+    assert.equal(element.computeLabelShortcut('polygerrit-review'), 'PR');
     assert.equal(
-      element._computeLabelShortcut(
-        'Invalid-Prolog-Rules-Label-Name--Verified'
-      ),
+      element.computeLabelShortcut('Invalid-Prolog-Rules-Label-Name--Verified'),
       'V'
     );
-    assert.equal(element._computeLabelShortcut('Some-Special-Label-7'), 'SSL7');
+    assert.equal(element.computeLabelShortcut('Some-Special-Label-7'), 'SSL7');
     assert.equal(
-      element._computeLabelShortcut('--Too----many----dashes---'),
+      element.computeLabelShortcut('--Too----many----dashes---'),
       'TMD'
     );
     assert.equal(
-      element._computeLabelShortcut(
+      element.computeLabelShortcut(
         'Really-rather-entirely-too-long-of-a-label-name'
       ),
       'RRETL'
     );
   });
 
-  test('colspans', () => {
+  test('colspans', async () => {
     element.sections = [{results: [{...createChange()}]}];
-    flush();
+    await element.updateComplete;
     const tdItemCount = queryAll<HTMLTableElement>(element, 'td').length;
 
-    const changeTableColumns: string[] | undefined = [];
+    element.visibleChangeTableColumns = [];
     const labelNames: string[] | undefined = [];
     assert.equal(
       tdItemCount,
-      element._computeColspan(
-        {results: [{...createChange()}]},
-        changeTableColumns,
-        labelNames
-      )
+      element.computeColspan({results: [{...createChange()}]}, labelNames)
     );
   });
 
   test('keyboard shortcuts', async () => {
-    sinon.stub(element, '_computeLabelNames');
+    sinon.stub(element, 'computeLabelNames');
     element.sections = [{results: new Array(1)}, {results: new Array(2)}];
     element.selectedIndex = 0;
     element.changes = [
@@ -187,12 +180,7 @@
       {...createChange(), _number: 1 as NumericChangeId},
       {...createChange(), _number: 2 as NumericChangeId},
     ];
-    await flush();
-    const promise = mockPromise();
-    afterNextRender(element, () => {
-      promise.resolve();
-    });
-    await promise;
+    await element.updateComplete;
     const elementItems = queryAll<GrChangeListItem>(
       element,
       'gr-change-list-item'
@@ -201,15 +189,18 @@
 
     assert.isTrue(elementItems[0].hasAttribute('selected'));
     pressKey(element, 'j');
+    await element.updateComplete;
     assert.equal(element.selectedIndex, 1);
     assert.isTrue(elementItems[1].hasAttribute('selected'));
     pressKey(element, 'j');
+    await element.updateComplete;
     assert.equal(element.selectedIndex, 2);
     assert.isTrue(elementItems[2].hasAttribute('selected'));
 
     const navStub = sinon.stub(GerritNav, 'navigateToChange');
     assert.equal(element.selectedIndex, 2);
     pressKey(element, Key.ENTER);
+    await element.updateComplete;
     assert.deepEqual(
       navStub.lastCall.args[0],
       {...createChange(), _number: 2 as NumericChangeId},
@@ -217,8 +208,10 @@
     );
 
     pressKey(element, 'k');
+    await element.updateComplete;
     assert.equal(element.selectedIndex, 1);
     pressKey(element, Key.ENTER);
+    await element.updateComplete;
     assert.deepEqual(
       navStub.lastCall.args[0],
       {...createChange(), _number: 1 as NumericChangeId},
@@ -231,9 +224,9 @@
     assert.equal(element.selectedIndex, 0);
   });
 
-  test('no changes', () => {
+  test('no changes', async () => {
     element.changes = [];
-    flush();
+    await element.updateComplete;
     const listItems = queryAll<GrChangeListItem>(
       element,
       'gr-change-list-item'
@@ -246,9 +239,9 @@
     assert.ok(noChangesMsg);
   });
 
-  test('empty sections', () => {
+  test('empty sections', async () => {
     element.sections = [{results: []}, {results: []}];
-    flush();
+    await element.updateComplete;
     const listItems = queryAll<GrChangeListItem>(
       element,
       'gr-change-list-item'
@@ -261,8 +254,8 @@
   suite('empty section', () => {
     test('not shown on empty non-outgoing sections', () => {
       const section = {name: 'test', query: 'test', results: []};
-      assert.isTrue(element._isEmpty(section));
-      assert.equal(element._getSpecialEmptySlot(section), '');
+      assert.isTrue(element.isEmpty(section));
+      assert.equal(element.getSpecialEmptySlot(section), '');
     });
 
     test('shown on empty outgoing sections', () => {
@@ -272,14 +265,14 @@
         results: [],
         isOutgoing: true,
       };
-      assert.isTrue(element._isEmpty(section));
-      assert.equal(element._getSpecialEmptySlot(section), 'empty-outgoing');
+      assert.isTrue(element.isEmpty(section));
+      assert.equal(element.getSpecialEmptySlot(section), 'empty-outgoing');
     });
 
     test('shown on empty outgoing sections', () => {
       const section = {name: YOUR_TURN.name, query: 'test', results: []};
-      assert.isTrue(element._isEmpty(section));
-      assert.equal(element._getSpecialEmptySlot(section), 'empty-your-turn');
+      assert.isTrue(element.isEmpty(section));
+      assert.equal(element.getSpecialEmptySlot(section), 'empty-your-turn');
     });
 
     test('not shown on non-empty outgoing sections', () => {
@@ -295,14 +288,14 @@
           },
         ],
       };
-      assert.isFalse(element._isEmpty(section));
+      assert.isFalse(element.isEmpty(section));
     });
   });
 
   suite('empty column preference', () => {
     let element: GrChangeList;
 
-    setup(() => {
+    setup(async () => {
       stubFlags('isEnabled').returns(true);
       element = basicFixture.instantiate();
       element.sections = [{results: [{...createChange()}]}];
@@ -312,8 +305,8 @@
         time_format: TimeFormat.HHMM_12,
         change_table: [],
       };
-      element._config = createServerInfo();
-      flush();
+      element.config = createServerInfo();
+      await element.updateComplete;
     });
 
     test('show number enabled', () => {
@@ -333,7 +326,7 @@
   suite('full column preference', () => {
     let element: GrChangeList;
 
-    setup(() => {
+    setup(async () => {
       stubFlags('isEnabled').returns(true);
       element = basicFixture.instantiate();
       element.sections = [{results: [{...createChange()}]}];
@@ -354,8 +347,8 @@
           ' Status ',
         ],
       };
-      element._config = createServerInfo();
-      flush();
+      element.config = createServerInfo();
+      await element.updateComplete;
     });
 
     test('all columns visible', () => {
@@ -371,7 +364,7 @@
   suite('partial column preference', () => {
     let element: GrChangeList;
 
-    setup(() => {
+    setup(async () => {
       stubFlags('isEnabled').returns(true);
       element = basicFixture.instantiate();
       element.sections = [{results: [{...createChange()}]}];
@@ -391,8 +384,8 @@
           ' Status ',
         ],
       };
-      element._config = createServerInfo();
-      flush();
+      element.config = createServerInfo();
+      await element.updateComplete;
     });
 
     test('all columns except repo visible', () => {
@@ -417,7 +410,7 @@
 
     /* This would only exist if somebody manually updated the config
     file. */
-    setup(() => {
+    setup(async () => {
       element = basicFixture.instantiate();
       element.account = {_account_id: 1001 as AccountId};
       element.preferences = {
@@ -425,7 +418,7 @@
         time_format: TimeFormat.HHMM_12,
         change_table: ['Bad'],
       };
-      flush();
+      await element.updateComplete;
     });
 
     test('bad column does not exist', () => {
@@ -446,43 +439,43 @@
 
     test('query without age and limit unchanged', () => {
       const query = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), query);
+      assert.deepEqual(element.processQuery(query), query);
     });
 
     test('query with age and limit', () => {
       const query = 'status:closed age:1week limit:10 owner:me';
       const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
+      assert.deepEqual(element.processQuery(query), expectedQuery);
     });
 
     test('query with age', () => {
       const query = 'status:closed age:1week owner:me';
       const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
+      assert.deepEqual(element.processQuery(query), expectedQuery);
     });
 
     test('query with limit', () => {
       const query = 'status:closed limit:10 owner:me';
       const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
+      assert.deepEqual(element.processQuery(query), expectedQuery);
     });
 
     test('query with age as value and not key', () => {
       const query = 'status:closed random:age';
       const expectedQuery = 'status:closed random:age';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
+      assert.deepEqual(element.processQuery(query), expectedQuery);
     });
 
     test('query with limit as value and not key', () => {
       const query = 'status:closed random:limit';
       const expectedQuery = 'status:closed random:limit';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
+      assert.deepEqual(element.processQuery(query), expectedQuery);
     });
 
     test('query with -age key', () => {
       const query = 'status:closed -age:1week';
       const expectedQuery = 'status:closed';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
+      assert.deepEqual(element.processQuery(query), expectedQuery);
     });
   });
 
@@ -518,12 +511,7 @@
           ],
         },
       ];
-      await flush();
-      const promise = mockPromise();
-      afterNextRender(element, () => {
-        promise.resolve();
-      });
-      await promise;
+      await element.updateComplete;
       const elementItems = queryAll<GrChangeListItem>(
         element,
         'gr-change-list-item'
@@ -565,23 +553,23 @@
       );
     });
 
-    test('_computeItemAbsoluteIndex', () => {
-      sinon.stub(element, '_computeLabelNames');
+    test('computeItemAbsoluteIndex', () => {
+      sinon.stub(element, 'computeLabelNames');
       element.sections = [
         {results: new Array(1)},
         {results: new Array(2)},
         {results: new Array(3)},
       ];
 
-      assert.equal(element._computeItemAbsoluteIndex(0, 0), 0);
+      assert.equal(element.computeItemAbsoluteIndex(0, 0), 0);
       // Out of range but no matter.
-      assert.equal(element._computeItemAbsoluteIndex(0, 1), 1);
+      assert.equal(element.computeItemAbsoluteIndex(0, 1), 1);
 
-      assert.equal(element._computeItemAbsoluteIndex(1, 0), 1);
-      assert.equal(element._computeItemAbsoluteIndex(1, 1), 2);
-      assert.equal(element._computeItemAbsoluteIndex(1, 2), 3);
-      assert.equal(element._computeItemAbsoluteIndex(2, 0), 3);
-      assert.equal(element._computeItemAbsoluteIndex(3, 0), 6);
+      assert.equal(element.computeItemAbsoluteIndex(1, 0), 1);
+      assert.equal(element.computeItemAbsoluteIndex(1, 1), 2);
+      assert.equal(element.computeItemAbsoluteIndex(1, 2), 3);
+      assert.equal(element.computeItemAbsoluteIndex(2, 0), 3);
+      assert.equal(element.computeItemAbsoluteIndex(3, 0), 6);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 9beaa58..1b04268 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -450,12 +450,8 @@
     this.$.commandsDialog.open();
   }
 
-  /**
-   * Returns `this` as the visibility observer target for the keyboard shortcut
-   * mixin to decide whether shortcuts should be enabled or not.
-   */
-  _computeObserverTarget() {
-    return this;
+  _handleSelectedIndexChanged(e: CustomEvent) {
+    this._selectedChangeIndex = Number(e.detail.value);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index a55befb..84cf6d9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -78,10 +78,10 @@
       show-star=""
       account="[[account]]"
       preferences="[[preferences]]"
-      selected-index="{{_selectedChangeIndex}}"
+      selected-index="[[_selectedChangeIndex]]"
       sections="[[_results]]"
+      on-selected-index-changed="_handleSelectedIndexChanged"
       on-toggle-star="_handleToggleStar"
-      observer-target="[[_computeObserverTarget()]]"
     >
       <div id="emptyOutgoing" slot="empty-outgoing">
         <template is="dom-if" if="[[_showNewUserHelp]]">