chromium/chrome/test/data/webui/side_panel/customize_chrome/wallpaper_search/combobox_test.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.

import 'chrome://customize-chrome-side-panel.top-chrome/wallpaper_search/combobox/customize_chrome_combobox.js';

import type {CustomizeChromeComboboxElement} from 'chrome://customize-chrome-side-panel.top-chrome/wallpaper_search/combobox/customize_chrome_combobox.js';
import {assertEquals, assertFalse, assertNotEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {eventToPromise, isVisible, microtasksFinished} from 'chrome://webui-test/test_util.js';

suite('ComboboxTest', () => {
  let combobox: CustomizeChromeComboboxElement;

  function getGroup(groupIndex: number): HTMLElement {
    return combobox.shadowRoot!.querySelectorAll('[role=group]')[groupIndex] as
        HTMLElement;
  }

  function getOptionFromGroup(
      groupIndex: number, optionIndex: number): HTMLElement {
    return getGroup(groupIndex)
               .querySelectorAll('[role=option]')[optionIndex] as HTMLElement;
  }

  function getDefaultOption(): HTMLElement {
    return combobox.shadowRoot!.querySelector('#defaultOption')!;
  }

  function getOption(optionIndex: number): HTMLElement {
    return combobox.shadowRoot!.querySelectorAll(
               '[role=option]:not(#defaultOption)')[optionIndex] as HTMLElement;
  }

  function toggleGroupExpand(groupIndex: number) {
    getGroup(groupIndex).querySelector('label')!.click();
  }

  function getHighlightedElement() {
    return combobox.shadowRoot!.querySelector('[highlighted]');
  }

  function open() {
    combobox.$.input.click();
    return microtasksFinished();
  }

  function keydown(key: string) {
    combobox.dispatchEvent(new KeyboardEvent('keydown', {key}));
    return microtasksFinished();
  }

  setup(async () => {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    combobox = document.createElement('customize-chrome-combobox');
    combobox.label = 'Label';
    combobox.defaultOptionLabel = 'Select a option';
    combobox.items = [
      {key: 'Key 1', label: 'Option 1'},
      {key: 'Key 2', label: 'Option 2'},
    ];
    document.body.appendChild(combobox);
    return microtasksFinished();
  });

  test('ShowsAndHides', async () => {
    assertFalse(isVisible(combobox.$.dropdown));
    await open();
    assertTrue(isVisible(combobox.$.dropdown));

    combobox.$.input.dispatchEvent(new FocusEvent('focusout'));
    await microtasksFinished();
    assertFalse(isVisible(combobox.$.dropdown));
  });

  test('OpensAndClosesDropdownOnKeydown', async () => {
    async function assertDropdownOpensAndHighlightsFirst(
        key: string, expectedHighlight: HTMLElement) {
      await keydown(key);
      assertTrue(isVisible(combobox.$.dropdown));
      assertEquals(
          expectedHighlight,
          combobox.shadowRoot!.querySelector('[highlighted]'));
      // Close the dropdown.
      return keydown('Escape');
    }

    await microtasksFinished();
    await assertDropdownOpensAndHighlightsFirst(
        'ArrowDown', getDefaultOption());
    await assertDropdownOpensAndHighlightsFirst('ArrowUp', getOption(1));
    await assertDropdownOpensAndHighlightsFirst('Home', getDefaultOption());
    await assertDropdownOpensAndHighlightsFirst('End', getOption(1));
    await assertDropdownOpensAndHighlightsFirst('Enter', getDefaultOption());
    await assertDropdownOpensAndHighlightsFirst('Space', getDefaultOption());
  });

  test('HighlightsItemsOnKeydownWhenOpen', async () => {
    combobox.items = [
      {
        key: 'Key A',
        label: 'Group A',
        items: [
          {key: 'Key A1', label: 'Option A1'},
          {key: 'KeyA2', label: 'OptionA2'},
        ],
      },
      {
        key: 'Key B',
        label: 'Group B',
        items: [{key: 'Key B1', label: 'Option B1'}],
      },
    ];
    await microtasksFinished();

    toggleGroupExpand(0);
    toggleGroupExpand(1);
    await microtasksFinished();

    const groupA = getGroup(0);
    const optionA1 = getOptionFromGroup(0, 0);
    const optionA2 = getOptionFromGroup(0, 1);
    const groupB = getGroup(1);
    const optionB1 = getOptionFromGroup(1, 0);

    // ArrowDown should loop through list.
    await open();
    await keydown('ArrowDown');
    assertEquals(getDefaultOption(), getHighlightedElement());
    await keydown('ArrowDown');
    assertEquals(groupA.querySelector('label'), getHighlightedElement());
    await keydown('ArrowDown');
    assertEquals(optionA1, getHighlightedElement());
    await keydown('ArrowDown');
    assertEquals(optionA2, getHighlightedElement());
    await keydown('ArrowDown');
    assertEquals(groupB.querySelector('label'), getHighlightedElement());
    await keydown('ArrowDown');
    assertEquals(optionB1, getHighlightedElement());
    await keydown('ArrowDown');
    assertEquals(getDefaultOption(), getHighlightedElement());

    // ArrowUp goes reverse order.
    await keydown('ArrowUp');
    assertEquals(optionB1, getHighlightedElement());

    // Home and End keys work.
    await keydown('Home');
    assertEquals(getDefaultOption(), getHighlightedElement());
    await keydown('End');
    assertEquals(optionB1, getHighlightedElement());

    // Closes when hitting Escape and resets highlight.
    await keydown('Escape');
    assertFalse(isVisible(combobox.$.dropdown));
    assertEquals(null, getHighlightedElement());
  });

  test('HighlightsOnPointerover', async () => {
    combobox.items = [
      {
        key: 'Key A',
        label: 'Group A',
        items: [{key: 'Key A1', label: 'Option A1'}],
      },
    ];
    await microtasksFinished();
    toggleGroupExpand(0);
    await microtasksFinished();

    const group = getGroup(0);
    const option = getOptionFromGroup(0, 0);

    // Open the dropdown.
    await open();

    group.querySelector('label')!.dispatchEvent(
        new PointerEvent('pointerover', {bubbles: true, composed: true}));
    assertEquals(group.querySelector('label'), getHighlightedElement());
    option.dispatchEvent(
        new PointerEvent('pointerover', {bubbles: true, composed: true}));
    assertEquals(option, getHighlightedElement());
  });

  test('HighlightsOnPointermoveAfterKeyEvent', async () => {
    const option1 = getOption(0);
    const option2 = getOption(1);

    // Open the dropdown.
    await open();

    // Mouse moves to first option.
    option1.dispatchEvent(
        new PointerEvent('pointerover', {bubbles: true, composed: true}));
    assertEquals(option1, getHighlightedElement());

    // Keydown down to highlight second option. Mouse still over first option.
    await keydown('ArrowDown');
    assertEquals(option2, getHighlightedElement());

    // Pointerover event over first option should not highlight first option,
    // since it follows a key event.
    option1.dispatchEvent(new PointerEvent('pointerover'));
    assertEquals(option2, getHighlightedElement());

    // Mock moving mouse within the first option again.
    option1.dispatchEvent(
        new PointerEvent('pointermove', {bubbles: true, composed: true}));
    assertEquals(option1, getHighlightedElement());
  });

  test('SelectsItem', async () => {
    combobox.items = [
      {
        key: 'Key A',
        label: 'Group A',
        items: [
          {key: 'I am key 1', label: 'I am option 1'},
          {key: 'I am key 2', label: 'I am option 2'},
        ],
      },
    ];
    await microtasksFinished();
    toggleGroupExpand(0);
    await microtasksFinished();

    const groupA = getGroup(0);
    const optionA1 = getOptionFromGroup(0, 0);
    let optionA2 = getOptionFromGroup(0, 1);

    // Open dropdown, click on first option to select it.
    await open();
    optionA1.click();
    await microtasksFinished();
    assertTrue(optionA1.hasAttribute('selected'));
    assertEquals('true', optionA1.ariaSelected);
    assertFalse(isVisible(combobox.$.dropdown));
    assertTrue(combobox.$.input.textContent!.includes('I am option 1'));

    // Open the dropdown back and arrow key to next option and select it.
    await open();
    await keydown('ArrowDown');
    assertFalse(optionA2.hasAttribute('selected'));
    assertEquals('false', optionA2.ariaSelected);
    await keydown('Enter');
    assertTrue(optionA2.hasAttribute('selected'));
    assertEquals('true', optionA2.ariaSelected);
    assertFalse(optionA1.hasAttribute('selected'));
    assertEquals('false', optionA1.ariaSelected);
    assertTrue(combobox.$.input.textContent!.includes('I am option 2'));
    assertFalse(isVisible(combobox.$.dropdown));

    // Pressing Enter or clicking on an unselectable item should not select it.
    await open();
    await keydown('Home');
    await keydown('ArrowDown');
    const groupAClickEvent = eventToPromise('click', groupA);
    await keydown('Enter');
    await groupAClickEvent;
    assertFalse(groupA.hasAttribute('selected'));
    groupA.click();
    await microtasksFinished();
    assertFalse(groupA.hasAttribute('selected'));

    // Need to re-query since `optionA2` from line 219 is no longer in the DOM,
    // after Lit re-renders and a new DOM node is created.
    optionA2 = getOptionFromGroup(0, 1);
    assertTrue(optionA2.hasAttribute('selected'));
    assertEquals('true', optionA2.ariaSelected);
    assertTrue(isVisible(combobox.$.dropdown));
  });

  test('UnselectsItems', async () => {
    await open();
    const option = getOption(0);

    // Clicking and re-clicking should unselect item.
    option.click();
    await microtasksFinished();
    assertTrue(option.hasAttribute('selected'));
    assertEquals('Key 1', combobox.value);
    option.click();
    await microtasksFinished();
    assertFalse(option.hasAttribute('selected'));
    assertEquals(undefined, combobox.value);

    // Unselecting by keyboard should also work.
    await keydown('Home');
    await keydown('ArrowDown');
    await keydown('Enter');
    assertTrue(option.hasAttribute('selected'));
    assertEquals('Key 1', combobox.value);
    await open();
    await keydown('Enter');
    assertFalse(option.hasAttribute('selected'));
    assertEquals(undefined, combobox.value);
  });

  test('NotifiesValueChange', async () => {
    const option1 = getOption(0);
    const option2 = getOption(1);

    let valueChangeEvent = eventToPromise('value-changed', combobox);
    await open();
    option1.click();
    await valueChangeEvent;
    assertEquals('Key 1', combobox.value);
    assertTrue(option1.hasAttribute('selected'));

    valueChangeEvent = eventToPromise('value-changed', combobox);
    await open();
    option2.click();
    await valueChangeEvent;
    assertEquals('Key 2', combobox.value);
    assertTrue(option2.hasAttribute('selected'));
  });

  test('UpdatesWithBoundValue', async () => {
    const option1 = getOption(0);
    const option2 = getOption(1);

    combobox.value = 'Key 1';
    await microtasksFinished();
    assertTrue(option1.hasAttribute('selected'));
    assertFalse(option2.hasAttribute('selected'));

    combobox.value = 'Key 2';
    await microtasksFinished();
    assertFalse(option1.hasAttribute('selected'));
    assertTrue(option2.hasAttribute('selected'));
  });

  test('SetsUniqueIdsAndAriaActiveDescendant', async () => {
    const option1 = getOption(0);
    const option2 = getOption(1);

    assertTrue(option1.id.includes('comboboxItem'));
    assertTrue(option2.id.includes('comboboxItem'));
    assertNotEquals(option1.id, option2.id);

    await open();
    await keydown('ArrowDown');
    assertEquals(
        'defaultOption',
        combobox.$.input.getAttribute('aria-activedescendant'));

    await keydown('ArrowDown');
    assertEquals(
        option1.id, combobox.$.input.getAttribute('aria-activedescendant'));

    await keydown('ArrowDown');
    assertEquals(
        option2.id, combobox.$.input.getAttribute('aria-activedescendant'));
  });

  test('ExpandsAndCollapsesCategories', async () => {
    combobox.items = [
      {
        key: 'Key A',
        label: 'Group A',
        items: [
          {key: 'I am key 1', label: 'I am option 1'},
          {key: 'I am key 2', label: 'I am option 2'},
        ],
      },
    ];
    await open();

    // Only the default option should be visible yet since group is by default
    // collapsed.
    assertEquals(
        1, combobox.shadowRoot!.querySelectorAll('[role=option]').length);

    const groupLabel = getGroup(0).querySelector('label')!;
    const groupLabelIcon = groupLabel.querySelector('cr-icon')!;
    assertEquals('false', groupLabel.ariaExpanded);
    assertEquals('cr:expand-more', groupLabelIcon.icon);

    // Clicking on a group expands the dropdown items below it.
    toggleGroupExpand(0);
    await microtasksFinished();
    const options = Array.from<HTMLElement>(
        combobox.shadowRoot!.querySelectorAll('[role=option]'));
    assertEquals(3, options.filter(option => isVisible(option)).length);

    assertEquals('true', groupLabel.ariaExpanded);
    assertEquals('cr:expand-less', groupLabelIcon.icon);

    // Clicking on the group again hides the dropdown items below it.
    toggleGroupExpand(0);
    await microtasksFinished();
    assertEquals(1, options.filter(option => isVisible(option)).length);
    assertEquals('false', groupLabel.ariaExpanded);
    assertEquals('cr:expand-more', groupLabelIcon.icon);
  });

  test('CheckmarksSelectedOption', async () => {
    combobox.items = [
      {key: 'Key 1', label: 'Option 1', imagePath: 'image/path1.png'},
      {key: 'Key 2', label: 'Option 2', imagePath: 'image/path2.png'},
    ];
    await microtasksFinished();

    const optionCheckmarks = combobox.shadowRoot!.querySelectorAll(
        'customize-chrome-check-mark-wrapper');
    assertEquals(2, optionCheckmarks.length);

    const option1Checkmark = optionCheckmarks[0]!;
    const option2Checkmark = optionCheckmarks[1]!;
    assertFalse(option1Checkmark.checked);
    assertFalse(option2Checkmark.checked);

    combobox.value = 'Key 1';
    await microtasksFinished();
    assertTrue(option1Checkmark.checked);
    assertFalse(option2Checkmark.checked);

    combobox.value = 'Key 2';
    await microtasksFinished();
    assertFalse(option1Checkmark.checked);
    assertTrue(option2Checkmark.checked);
  });

  test('SelectingDefaultOptionResetsValue', async () => {
    await open();
    getOption(0).click();
    await microtasksFinished();
    assertEquals('Key 1', combobox.value);

    getDefaultOption().click();
    await microtasksFinished();
    assertEquals(undefined, combobox.value);
    assertEquals('true', getDefaultOption().getAttribute('aria-selected'));
  });

  test('IndentsDefaultOption', async () => {
    const defaultOptionStyles = window.getComputedStyle(getDefaultOption());
    assertEquals('44px', defaultOptionStyles.paddingInlineStart);

    // Groups should not indent default option.
    combobox.items = [
      {
        key: 'Key A',
        label: 'Group A',
        items: [
          {key: 'I am key 1', label: 'I am option 1'},
          {key: 'I am key 2', label: 'I am option 2'},
        ],
      },
    ];
    await microtasksFinished();
    assertEquals('20px', defaultOptionStyles.paddingInlineStart);

    // Items with images should not indent.
    combobox.items = [
      {key: 'Key 1', label: 'Option 1', imagePath: 'image/path1.png'},
    ];
    await microtasksFinished();
    assertEquals('20px', defaultOptionStyles.paddingInlineStart);
  });
});