chromium/chrome/browser/resources/settings/controls/settings_checkbox_list_entry.ts

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

/**
 * @fileoverview 'settings-checkbox-list-entry' is a wrapper for cr-checkbox so
 * that it can have the correct accessibility behavior while inside an
 * iron-list. Because cr-checkbox passes its focus to its inner checkbox
 * element, screen readers are unable to infer a parent-child relationship
 * between the list element and the focused checkbox. As a result using the
 * roles listbox/option and annotating with aria-setsize/aria-posinset will not
 * work properly.
 *
 * To fix this 'settings-checkbox-list-entry' hijacks focus and prevents it from
 * going into the inner element, so that screenreaders will properly read
 * "(x of y)". This however changes the visuals so that when the element is
 * focused the entire row is highlighted instead of just the checkbox.
 *
 * Example usage:
 * <iron-list role="listbox" items="[[items]]">
 *   <template>
 *     <settings-checkbox-list-entry role="option"
 *         checked="[[isSelected_(item)]]"
 *         tabindex="[[tabIndex]]"
 *         aria-posinset$="[[addOneTo_(index)]]"
 *         aria-setsize$="[[items.length]]"
 *         on-change="toggleSelection_">
 *       [[item]]
 *     </settings-checkbox-list-entry>
 *   </template>
 * </iron-list>
 */
import 'chrome://resources/cr_elements/cr_checkbox/cr_checkbox.js';
import '../settings_shared.css.js';
import '../site_favicon.js';

import type {CrCheckboxElement} from 'chrome://resources/cr_elements/cr_checkbox/cr_checkbox.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './settings_checkbox_list_entry.html.js';

export interface SettingsCheckboxListEntryElement {
  $: {
    checkbox: CrCheckboxElement,
  };
}

export class SettingsCheckboxListEntryElement extends PolymerElement {
  static get is() {
    return 'settings-checkbox-list-entry';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      // Used to set the status of the checkbox when the entry is created,
      // as well as when the list item for the entry changes.
      checked: {
        type: Boolean,
        value: false,
        observer: 'onCheckedChanged_',
      },

      // Reflects to the tabindex attribute. When it is negative (non-focusable)
      // aria-hidden will be set to "true", so that it will be ignored by screen
      // readers. This is needed because iron-list recycles its entries, so when
      // focusing an entry, the screen reader can be confused by other entries'
      // aria-posinset and aria-setsize attributes if they aren't aria-hidden.
      tabindex: {
        type: Number,
        value: 0,
        observer: 'onTabIndexChanged_',
        reflectToAttribute: true,
      },
    };
  }

  checked: boolean;
  private posinset: number;
  private setsize: number;
  private tabindex: number;

  override ready() {
    super.ready();
    this.addEventListener('click', this.onClick_);
    this.addEventListener('keydown', this.onKeyDown_);
    this.addEventListener('keyup', this.onKeyUp_);
  }

  private onClick_() {
    this.$.checkbox.click();
  }

  // Handle key presses in the same way as cr-checkbox, because it no longer
  // receives focus.
  private onKeyDown_(e: KeyboardEvent) {
    if (e.key !== ' ' && e.key !== 'Enter') {
      return;
    }

    e.preventDefault();
    e.stopPropagation();
    if (e.repeat) {
      return;
    }

    if (e.key === 'Enter') {
      this.$.checkbox.click();
    }
  }

  private onKeyUp_(e: KeyboardEvent) {
    if (e.key === ' ' || e.key === 'Enter') {
      e.preventDefault();
      e.stopPropagation();
    }

    if (e.key === ' ') {
      this.$.checkbox.click();
    }
  }

  private onCheckedChanged_() {
    this.setAttribute('aria-checked', String(this.$.checkbox.checked));
  }

  private onTabIndexChanged_() {
    this.setAttribute('aria-hidden', this.tabindex >= 0 ? 'false' : 'true');
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-checkbox-list-entry': SettingsCheckboxListEntryElement;
  }
}

customElements.define(
    SettingsCheckboxListEntryElement.is, SettingsCheckboxListEntryElement);