class TfFilterableCheckboxList extends LegacyElementMixin()

in tensorboard/components/tf_dashboard_common/tf-filterable-checkbox-list.ts [46:359]


class TfFilterableCheckboxList extends LegacyElementMixin(PolymerElement) {
  static readonly template = html`
    <style include="scrollbar-style"></style>
    <style include="run-color-style"></style>
    <div class="item">
      <paper-input
        id="input"
        autofocus=""
        class="input"
        no-label-float=""
        label="Write a regex to filter [[label]]s"
        value="[[_regexString]]"
        on-bind-value-changed="_debouncedRegexChange"
      >
      </paper-input>
    </div>
    <div class="matches">
      <template is="dom-if" if="[[!_itemsMatchingRegex.length]]">
        <div class="item empty-match">No matches</div>
      </template>
      <template
        is="dom-repeat"
        items="[[_itemsMatchingRegex]]"
        on-dom-change="_synchronizeColors"
      >
        <div class="item">
          <paper-checkbox
            checked$="[[_isChecked(item, selectionState.*)]]"
            class="checkbox"
            name="[[item]]"
            on-tap="_checkboxTapped"
            title="Alt+Click to select only [[item.title]]."
          >
            <span>[[item.title]]</span>

            <template is="dom-if" if="[[item.subtitle]]">
              <span class="subtitle">[[item.subtitle]]</span>
            </template>
          </paper-checkbox>
        </div>
      </template>
    </div>
    <template is="dom-if" if="[[!allToggleDisabled]]">
      <div class="item">
        <paper-button class="x-button" on-tap="_toggleAll">
          Toggle All [[label]]s
        </paper-button>
      </div>
    </template>
    <style>
      paper-input {
        --paper-input-container-focus-color: var(--tb-orange-strong);
        --paper-input-container-input: {
          font-size: 14px;
        }
        --paper-input-container-label: {
          font-size: 14px;
        }
      }

      paper-checkbox {
        --paper-checkbox-ink-size: 40px;
        --paper-checkbox-label: {
          display: flex;
          flex-direction: column;
          overflow: hidden;
          text-overflow: ellipsis;
          word-break: break-word;
        }
      }

      .subtitle {
        color: var(--paper-grey-700);
        font-size: 13px;
      }

      paper-menu-button {
        padding: 0;
      }

      paper-ripple {
        top: 12px;
        left: 0;
        right: 0;
        bottom: 8px;
      }

      .dropdown-content {
        padding: 10px 0;
      }

      .input {
        width: 100%;
      }

      .item {
        align-items: center;
        box-sizing: border-box;
        display: flex;
        flex-direction: row;
        font-family: inherit;
        font-size: 14px;
        line-height: 24px;
        margin: 3px 0;
        padding: 0 16px;
        max-width: var(--tf-filterable-checkbox-list-content-max-width, 450px);
      }

      .matches {
        max-height: 300px;
        overflow-y: auto;
        padding: 5px 0;
      }

      .checkbox {
        width: 100%;
        display: flex;
        padding: 5px 0;
        align-items: center;
      }

      .empty-match {
        color: var(--paper-grey-600);
        font-size: 14px;
      }

      .x-button {
        width: 100%;
      }
    </style>
  `;

  @property({type: String})
  label: string;

  @property({
    type: Boolean,
  })
  useCheckboxColors: boolean = true;

  @property({
    type: Object,
  })
  coloring: {getColor: (FilterableCheckboxListItem) => string} = {
    getColor: (item: FilterableCheckboxListItem): string => '',
  };

  @property({
    type: Array,
    observer: '_pruneSelectionState',
  })
  items: FilterableCheckboxListItem[] = [];

  @property({
    type: String,
  })
  _regexString: string = '';

  @property({
    type: Array,
    computed: 'computeItemsMatchingRegex(items.*, _regex)',
  })
  _itemsMatchingRegex: FilterableCheckboxListItem[];

  @property({
    // if an item is explicitly enabled, True, if explicitly disabled, False.
    // if undefined, default value (enable for first k items, disable after).
    type: Object,
  })
  selectionState: object = () => ({});

  @property({
    type: Array,
    notify: true,
    computed: '_computeSelectedItems(_itemsMatchingRegex.*, selectionState.*)',
  })
  selectedItems: unknown[];

  @property({
    // When TB first loads, if it has k or fewer items, they are all enabled
    // by default. If there are more, then all items are disabled.
    type: Number,
  })
  maxItemsToEnableByDefault: number = 40;

  @property({
    type: Boolean,
  })
  allToggleDisabled: boolean = false;

  override detached() {
    this.cancelDebouncer('_setRegex');
  }

  @computed('_regexString')
  get _regex(): RegExp | null {
    var regexString = this._regexString;
    try {
      return new RegExp(regexString);
    } catch (e) {
      return null;
    }
  }

  computeItemsMatchingRegex(__, ___) {
    const regex = this._regex;
    return regex ? this.items.filter((n) => regex.test(n.title)) : this.items;
  }

  _computeSelectedItems(__, ___) {
    const selectionState = this.selectionState;
    const num = this.maxItemsToEnableByDefault;
    const allEnabled = this._itemsMatchingRegex.length <= num;
    return this._itemsMatchingRegex.filter((n) => {
      return selectionState[n.id] == null ? allEnabled : selectionState[n.id];
    });
  }
  _isChecked(item, _) {
    return this.selectedItems.indexOf(item) != -1;
  }
  // ================== EVENT LISTENERS ===================
  _debouncedRegexChange() {
    const val = (this.$.input as any).value;
    if (val == '') {
      // If the user cleared the field, they may be done typing, so
      // update more quickly.
      window.requestAnimationFrame(() => {
        this._regexString = val;
      });
    } else {
      this.debounce(
        '_setRegex',
        () => {
          this._regexString = val;
        },
        150
      );
    }
  }

  @observe('coloring')
  _synchronizeColors() {
    var e = this.coloring;
    const checkboxes = this.root.querySelectorAll('paper-checkbox');
    checkboxes.forEach((cb) => {
      // Setting the null value will clear previously set color.
      const color = this.useCheckboxColors
        ? this.coloring.getColor((cb as any).name)
        : null;
      const customStyle = (cb as any).customStyle;
      customStyle['--paper-checkbox-checked-color'] = color;
      customStyle['--paper-checkbox-checked-ink-color'] = color;
      customStyle['--paper-checkbox-unchecked-color'] = color;
      customStyle['--paper-checkbox-unchecked-ink-color'] = color;
    });
    // The updateStyles call fails silently if the browser does not have focus,
    // e.g., if TensorBoard was opened into a new tab that is not visible.
    // So we wait for requestAnimationFrame.
    window.requestAnimationFrame(() => this.updateStyles());
  }

  _checkboxTapped(e) {
    const checkbox = e.currentTarget;
    const newSelectedNames = Object.assign({}, this.selectionState, {
      [checkbox.name.id]: checkbox.checked,
    });
    // If user presses alt while toggling checkbox, it deselects all items but
    // the clicked one.
    if (
      e.detail.sourceEvent instanceof MouseEvent &&
      e.detail.sourceEvent.altKey
    ) {
      Object.keys(newSelectedNames).forEach((key) => {
        newSelectedNames[key] = key == checkbox.name.id;
      });
    }
    // n.b. notifyPath won't work because names may have periods.
    this.selectionState = newSelectedNames;
  }

  _toggleAll() {
    let anyToggledOn = this._itemsMatchingRegex.some(
      (n) => this.selectionState[n.id]
    );
    const selectionStateIsDefault =
      Object.keys(this.selectionState).length == 0;
    const defaultOff =
      this._itemsMatchingRegex.length > this.maxItemsToEnableByDefault;
    // We have names toggled either if some were explicitly toggled on, or if
    // we are in the default state, and there are few enough that we default
    // to toggling on.
    anyToggledOn = anyToggledOn || (selectionStateIsDefault && !defaultOff);
    // If any are toggled on, we turn everything off. Or, if none are toggled
    // on, we turn everything on.
    const newSelection = {};
    this.items.forEach((n) => {
      newSelection[n.id] = !anyToggledOn;
    });
    this.selectionState = newSelection;
  }

  /**
   * Remove selection state of an item that no longer exists in the `items`.
   */
  _pruneSelectionState() {
    // Object key turns numbered keys into string.
    const itemIds = new Set(this.items.map(({id}) => String(id)));
    const newSelection = Object.assign({}, this.selectionState);
    Object.keys(newSelection).forEach((key) => {
      if (!itemIds.has(key)) delete newSelection[key];
    });
    this.selectionState = newSelection;
  }
}