chromium/ui/webui/resources/cr_elements/cr_grid/cr_grid.ts

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert} from '//resources/js/assert.js';
import {hasKeyModifiers} from '//resources/js/util.js';
import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';

import {getCss} from './cr_grid.css.js';
import {getHtml} from './cr_grid.html.js';

// Displays children in a two-dimensional grid and supports focusing children
// with arrow keys.

export interface CrGridElement {
  $: {
    items: HTMLSlotElement,
  };
}

export class CrGridElement extends CrLitElement {
  static get is() {
    return 'cr-grid';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      columns: {type: Number},
      disableArrowNavigation: {type: Boolean},
      focusSelector: {type: String},
      ignoreModifiedKeyEvents: {type: Boolean},
    };
  }

  columns: number = 1;
  disableArrowNavigation: boolean = false;
  focusSelector?: string;
  ignoreModifiedKeyEvents: boolean = false;

  override updated(changedProperties: PropertyValues<this>) {
    super.updated(changedProperties);

    if (changedProperties.has('columns')) {
      this.style.setProperty('--cr-grid-columns', String(this.columns));
    }
  }

  private getSlottedParent_(element: HTMLElement): HTMLElement {
    let parent = element;

    while (parent.assignedSlot !== this.$.items &&
           parent.parentElement !== null) {
      parent = parent.parentElement;
    }

    assert(parent);
    return parent;
  }

  protected onKeyDown_(e: KeyboardEvent) {
    if (!this.disableArrowNavigation &&
        ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
      const items =
          (this.$.items.assignedElements() as HTMLElement[]).filter(el => {
            return !!(
                el.offsetWidth || el.offsetHeight ||
                el.getClientRects().length);
          });
      const currentIndex =
          items.indexOf(this.getSlottedParent_(e.target as HTMLElement));
      const isRtl = window.getComputedStyle(this)['direction'] === 'rtl';
      const bottomRowColumns = items.length % this.columns;
      const direction = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : -1;
      const inEdgeRow = direction === 1 ?
          currentIndex >= items.length - bottomRowColumns :
          currentIndex < this.columns;
      let delta = 0;
      switch (e.key) {
        case 'ArrowLeft':
        case 'ArrowRight':
          // Ignores keys likely to be browse shortcuts (like Alt+Left for
          // back).
          if (this.ignoreModifiedKeyEvents && hasKeyModifiers(e)) {
            return;
          }

          delta = direction * (isRtl ? -1 : 1);
          break;
        case 'ArrowUp':
        case 'ArrowDown':
          delta = direction * (inEdgeRow ? bottomRowColumns : this.columns);
          break;
      }
      // Handle cases where we move to an empty space in a non-full bottom row
      // and have to jump to the next row.
      if (e.key === 'ArrowUp' && inEdgeRow &&
          currentIndex >= bottomRowColumns) {
        delta -= this.columns;
      } else if (
          e.key === 'ArrowDown' && !inEdgeRow &&
          currentIndex + delta >= items.length) {
        delta += bottomRowColumns;
      }

      e.preventDefault();
      const newIndex = (items.length + currentIndex + delta) % items.length;
      const item = items[newIndex]!;
      const toFocus = this.focusSelector ?
          item.querySelector<HTMLElement>(this.focusSelector) :
          item;
      assert(toFocus);
      toFocus.focus();
    }

    if (['Enter', ' '].includes(e.key)) {
      e.preventDefault();
      e.stopPropagation();
      (e.target as HTMLElement).click();
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-grid': CrGridElement;
  }
}

customElements.define(CrGridElement.is, CrGridElement);