chromium/ui/webui/resources/cr_elements/cr_icon/cr_iconset.ts

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

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

import type {Iconset} from './iconset_map.js';
import {IconsetMap} from './iconset_map.js';
import {getCss} from './cr_iconset.css.js';
import {getHtml} from './cr_iconset.html.js';

const APPLIED_ICON_CLASS: string = 'cr-iconset-svg-icon_';

export interface CrIconsetElement {
  $: {
    baseSvg: SVGElement,
  };
}

export class CrIconsetElement extends CrLitElement implements Iconset {
  static get is() {
    return 'cr-iconset';
  }

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

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

  static override get properties() {
    return {
      /**
       * The name of the iconset.
       */
      name: {type: String},

      /**
       * The size of an individual icon. Note that icons must be square.
       */
      size: {type: Number},
    };
  }

  name: string = '';
  size: number = 24;

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

    if (changedProperties.has('name')) {
      assert(changedProperties.get('name') === undefined);
      IconsetMap.getInstance().set(this.name, this);
    }
  }

  /**
   * Applies an icon to the given element.
   *
   * An svg icon is prepended to the element's shadowRoot, which should always
   * exist.
   * @param element Element to which the icon is applied.
   * @param iconName Name of the icon to apply.
   * @return The svg element which renders the icon.
   */
  applyIcon(element: HTMLElement, iconName: string): SVGElement|null {
    // Remove old svg element
    this.removeIcon(element);
    // install new svg element
    const svg = this.cloneIcon_(iconName);
    if (svg) {
      // Add special class so we can identify it in remove.
      svg.classList.add(APPLIED_ICON_CLASS);
      // insert svg element into shadow root
      element.shadowRoot!.insertBefore(svg, element.shadowRoot!.childNodes[0]!);
      return svg;
    }
    return null;
  }

  /**
   * Produce installable clone of the SVG element matching `id` in this
   * iconset, or null if there is no matching element.
   * @param iconName Name of the icon to apply.
   */
  createIcon(iconName: string): SVGElement|null {
    return this.cloneIcon_(iconName);
  }

  /**
   * Remove an icon from the given element by undoing the changes effected
   * by `applyIcon`.
   */
  removeIcon(element: HTMLElement) {
    // Remove old svg element
    const oldSvg = element.shadowRoot!.querySelector<SVGElement>(
        `.${APPLIED_ICON_CLASS}`);
    if (oldSvg) {
      oldSvg.remove();
    }
  }

  /**
   * Produce installable clone of the SVG element matching `id` in this
   * iconset, or `undefined` if there is no matching element.
   *
   * Returns an installable clone of the SVG element matching `id` or null if
   * no such element exists.
   */
  private cloneIcon_(id: string): SVGElement|null {
    const sourceSvg = this.querySelector(`g[id="${id}"]`);
    if (!sourceSvg) {
      return null;
    }

    const svgClone = this.$.baseSvg.cloneNode(true) as SVGElement;
    const content = sourceSvg.cloneNode(true) as SVGGElement;
    content.removeAttribute('id');
    const contentViewBox = content.getAttribute('viewBox');
    if (contentViewBox) {
      svgClone.setAttribute('viewBox', contentViewBox);
    }
    svgClone.appendChild(content);
    return svgClone;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-iconset': CrIconsetElement;
  }
}

customElements.define(CrIconsetElement.is, CrIconsetElement);