chromium/chrome/test/data/webui/cr_elements/cr_slider_test.ts

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

// clang-format off
import 'chrome://resources/cr_elements/cr_slider/cr_slider.js';

import {getTrustedHTML} from 'chrome://resources/js/static_types.js';
import type {CrSliderElement} from 'chrome://resources/cr_elements/cr_slider/cr_slider.js';
import {pressAndReleaseKeyOn} from 'chrome://webui-test/keyboard_mock_interactions.js';
import {assertEquals, assertFalse, assertTrue, assertNotReached} from 'chrome://webui-test/chai_assert.js';
import {eventToPromise, isVisible, microtasksFinished} from 'chrome://webui-test/test_util.js';
// clang-format on

suite('cr-slider', function() {
  let crSlider: CrSliderElement;

  setup(function() {
    document.body.innerHTML = getTrustedHTML`
      <style>
        #wrapper {
          width: 200px;
        }
      </style>
      <div id="wrapper">
        <cr-slider min="0" max="100"></cr-slider>
      </div>
    `;

    crSlider = document.body.querySelector('cr-slider')!;
    crSlider.value = 0;
    return microtasksFinished();
  });

  function checkDisabled(expected: boolean) {
    assertEquals(
        expected,
        window.getComputedStyle(crSlider).getPropertyValue('pointer-events') ===
            'none');
    const expectedTabindex = expected ? '-1' : '0';
    assertEquals(expectedTabindex, crSlider.getAttribute('tabindex'));
  }

  function pressArrowRight() {
    pressAndReleaseKeyOn(crSlider, 39, [], 'ArrowRight');
  }

  function pressArrowLeft() {
    pressAndReleaseKeyOn(crSlider, 37, [], 'ArrowLeft');
  }

  function pressPageUp() {
    pressAndReleaseKeyOn(crSlider, 33, [], 'PageUp');
  }

  function pressPageDown() {
    pressAndReleaseKeyOn(crSlider, 34, [], 'PageDown');
  }

  function pressArrowUp() {
    pressAndReleaseKeyOn(crSlider, 38, [], 'ArrowUp');
  }

  function pressArrowDown() {
    pressAndReleaseKeyOn(crSlider, 40, [], 'ArrowDown');
  }

  function pressHome() {
    pressAndReleaseKeyOn(crSlider, 36, [], 'Home');
  }

  function pressEnd() {
    pressAndReleaseKeyOn(crSlider, 35, [], 'End');
  }

  function pointerEvent(eventType: string, ratio: number) {
    const rect = crSlider.$.container.getBoundingClientRect();
    crSlider.dispatchEvent(new PointerEvent(eventType, {
      buttons: 1,
      pointerId: 1,
      clientX: rect.left + (ratio * rect.width),
    }));
  }

  function pointerDown(ratio: number) {
    pointerEvent('pointerdown', ratio);
  }

  function pointerMove(ratio: number) {
    pointerEvent('pointermove', ratio);
  }

  function pointerUp() {
    // Ignores clientX for pointerup event.
    pointerEvent('pointerup', 0);
  }

  test('key events', () => {
    pressArrowRight();
    assertEquals(1, crSlider.value);
    pressPageUp();
    assertEquals(2, crSlider.value);
    pressArrowUp();
    assertEquals(3, crSlider.value);
    pressHome();
    assertEquals(0, crSlider.value);
    pressArrowLeft();
    assertEquals(0, crSlider.value);
    pressArrowDown();
    assertEquals(0, crSlider.value);
    pressPageDown();
    assertEquals(0, crSlider.value);
    pressEnd();
    assertEquals(100, crSlider.value);
    pressArrowRight();
    assertEquals(100, crSlider.value);
    pressPageUp();
    assertEquals(100, crSlider.value);
    pressArrowUp();
    assertEquals(100, crSlider.value);
    pressArrowLeft();
    assertEquals(99, crSlider.value);
    pressArrowDown();
    assertEquals(98, crSlider.value);
    pressPageDown();
    assertEquals(97, crSlider.value);
  });

  test('key events with key down intervals', async () => {
    crSlider.keyPressSliderIncrement = 10;
    await microtasksFinished();

    pressArrowRight();
    assertEquals(10, crSlider.value);
    pressPageUp();
    assertEquals(20, crSlider.value);
    pressArrowUp();
    assertEquals(30, crSlider.value);
    pressHome();
    assertEquals(0, crSlider.value);
    pressArrowLeft();
    assertEquals(0, crSlider.value);
    pressArrowDown();
    assertEquals(0, crSlider.value);
    pressPageDown();
    assertEquals(0, crSlider.value);
    pressEnd();
    assertEquals(100, crSlider.value);
    pressArrowRight();
    assertEquals(100, crSlider.value);
    pressPageUp();
    assertEquals(100, crSlider.value);
    pressArrowUp();
    assertEquals(100, crSlider.value);
    pressArrowLeft();
    assertEquals(90, crSlider.value);
    pressArrowDown();
    assertEquals(80, crSlider.value);
    pressPageDown();
    assertEquals(70, crSlider.value);

    // Verify value stays within bounds.
    crSlider.value = 98;
    pressArrowRight();
    assertEquals(100, crSlider.value);
    crSlider.value = 2;
    pressArrowLeft();
    assertEquals(0, crSlider.value);
  });

  test('no-keybindings', () => {
    crSlider.noKeybindings = true;
    pressArrowRight();
    assertEquals(0, crSlider.value);
    crSlider.noKeybindings = false;
    pressArrowRight();
    assertEquals(1, crSlider.value);
    crSlider.noKeybindings = true;
    pressArrowRight();
    assertEquals(1, crSlider.value);
    crSlider.noKeybindings = false;
    pressArrowRight();
    assertEquals(2, crSlider.value);
  });

  test('mouse events', () => {
    assertFalse(crSlider.dragging);
    pointerMove(.25);
    assertEquals(0, crSlider.value);
    pointerDown(.5);
    assertTrue(crSlider.dragging);
    assertEquals(50, crSlider.value);
    pointerMove(.75);
    assertEquals(75, crSlider.value);
    pointerMove(-1);
    assertEquals(0, crSlider.value);
    pointerMove(2);
    assertEquals(100, crSlider.value);
    pointerUp();
    assertEquals(100, crSlider.value);
    assertFalse(crSlider.dragging);
    pointerMove(.25);
    assertEquals(100, crSlider.value);
  });

  test('snaps to closest value after minimum traversal', () => {
    // Snaps to closest value after traversing a minimum of .8 units.
    const tolerance = .000001;
    crSlider.snaps = true;
    crSlider.ticks = [];
    pointerDown(.501);
    assertEquals(50, crSlider.value);
    pointerMove(.505);
    assertEquals(50, crSlider.value);
    // Before threshold.
    pointerMove(.508 - tolerance);
    assertEquals(50, crSlider.value);
    // After threshold.
    pointerMove(.508 + tolerance);
    assertEquals(51, crSlider.value);
    // Before threshold.
    pointerMove(.502 + tolerance);
    assertEquals(51, crSlider.value);
    // After threshold.
    pointerMove(.502 - tolerance);
    assertEquals(50, crSlider.value);
    // Move far away rounds to closest whole number.
    pointerMove(.605);
    assertEquals(61, crSlider.value);
  });

  test('markers', async () => {
    const markersElement =
        crSlider.shadowRoot!.querySelector<HTMLElement>('#markers')!;
    assertTrue(markersElement.hidden);
    crSlider.markerCount = 10;
    await microtasksFinished();
    assertFalse(markersElement.hidden);
    const markers =
        Array.from(crSlider.shadowRoot!.querySelectorAll('#markers div'));
    assertEquals(9, markers.length);
    markers.forEach(marker => {
      assertTrue(marker.classList.contains('inactive-marker'));
    });
    crSlider.value = 100;
    await microtasksFinished();
    markers.forEach(marker => {
      assertTrue(marker.classList.contains('active-marker'));
    });
    crSlider.value = 50;
    await microtasksFinished();
    markers.slice(0, 5).forEach(marker => {
      assertTrue(marker.classList.contains('active-marker'));
    });
    markers.slice(5).forEach(marker => {
      assertTrue(marker.classList.contains('inactive-marker'));
    });
  });

  test('ticks and aria', async () => {
    crSlider.value = 2;
    crSlider.ticks = [1, 2, 4, 8];
    await microtasksFinished();

    assertEquals('1', crSlider.getAttribute('aria-valuemin'));
    assertEquals('8', crSlider.getAttribute('aria-valuemax'));
    assertEquals('4', crSlider.getAttribute('aria-valuetext'));
    assertEquals('4', crSlider.getAttribute('aria-valuenow'));
    assertEquals(
        '', crSlider.shadowRoot!.querySelector('#label')!.textContent!.trim());
    assertEquals(2, crSlider.value);
    pressArrowRight();
    assertEquals(3, crSlider.value);
    await microtasksFinished();
    assertEquals('8', crSlider.getAttribute('aria-valuetext'));
    assertEquals('8', crSlider.getAttribute('aria-valuenow'));
    assertEquals(
        '', crSlider.shadowRoot!.querySelector('#label')!.textContent!.trim());

    crSlider.value = 2;
    crSlider.ticks = [
      {
        value: 10,
        ariaValue: 1,
        label: 'First',
      },
      {
        value: 20,
        label: 'Second',
      },
      {
        value: 30,
        ariaValue: 3,
        label: 'Third',
      },
    ];
    await microtasksFinished();

    assertEquals('1', crSlider.getAttribute('aria-valuemin'));
    assertEquals('3', crSlider.getAttribute('aria-valuemax'));
    assertEquals('Third', crSlider.getAttribute('aria-valuetext'));
    assertEquals(
        'Third',
        crSlider.shadowRoot!.querySelector('#label')!.textContent!.trim());
    assertEquals('3', crSlider.getAttribute('aria-valuenow'));
    pressArrowLeft();
    await microtasksFinished();
    assertEquals('Second', crSlider.getAttribute('aria-valuetext'));
    assertEquals('20', crSlider.getAttribute('aria-valuenow'));
    assertEquals(
        'Second',
        crSlider.shadowRoot!.querySelector('#label')!.textContent!.trim());
  });

  test('disabled whenever public |disabled| is true', async () => {
    crSlider.disabled = true;
    crSlider.ticks = [];
    await microtasksFinished();
    checkDisabled(true);

    crSlider.ticks = [1];
    await microtasksFinished();
    checkDisabled(true);

    crSlider.ticks = [1, 2, 3];
    await microtasksFinished();
    checkDisabled(true);
  });

  test('not disabled or snaps when |ticks| is empty', async () => {
    assertFalse(crSlider.disabled);
    crSlider.ticks = [];
    await microtasksFinished();
    checkDisabled(false);
    assertFalse(crSlider.snaps);
    assertEquals(0, crSlider.min);
    assertEquals(100, crSlider.max);
  });

  test('effectively disabled when only one tick', async () => {
    assertFalse(crSlider.disabled);
    crSlider.ticks = [1];
    await microtasksFinished();
    checkDisabled(true);
    assertFalse(crSlider.snaps);
    assertEquals(0, crSlider.min);
    assertEquals(100, crSlider.max);
  });

  test('not disabled and |snaps| true when |ticks.length| > 0', async () => {
    assertFalse(crSlider.disabled);
    crSlider.ticks = [1, 2, 3];
    await microtasksFinished();
    checkDisabled(false);
    assertTrue(crSlider.snaps);
    assertEquals(0, crSlider.min);
    assertEquals(2, crSlider.max);
  });

  test(
      'disabled, max, min and snaps update when ticks is mutated', async () => {
        assertFalse(crSlider.disabled);
        checkDisabled(false);

        // Single tick is effectively disabled.
        let ticks = crSlider.ticks.slice() as number[];
        ticks.push(1);
        crSlider.ticks = ticks;
        await microtasksFinished();
        checkDisabled(true);
        assertFalse(crSlider.snaps);
        assertEquals(0, crSlider.min);
        assertEquals(100, crSlider.max);

        // Multiple ticks is enabled.
        ticks = crSlider.ticks.slice();
        ticks.push(2);
        crSlider.ticks = ticks;
        await microtasksFinished();
        checkDisabled(false);
        assertTrue(crSlider.snaps);
        assertEquals(0, crSlider.min);
        assertEquals(1, crSlider.max);
      });

  test('value updated before dragging-changed event handled', () => {
    const wait = new Promise<void>(resolve => {
      crSlider.addEventListener('dragging-changed', e => {
        if (!(e as CustomEvent<{value: number}>).detail.value) {
          assertEquals(50, crSlider.value);
          resolve();
        }
      });
    });
    pointerDown(0);
    pointerMove(.5);
    pointerUp();
    return wait;
  });

  test('smooth position transition only on pointerdown', async () => {
    function assertNoTransition() {
      const expected = 'all';
      assertEquals(
          expected,
          getComputedStyle(crSlider.shadowRoot!.querySelector('#knobAndLabel')!)
              .transition);
      assertEquals(
          expected,
          getComputedStyle(crSlider.shadowRoot!.querySelector('#bar')!)
              .transition);
    }

    const knobAndLabel =
        crSlider.shadowRoot!.querySelector<HTMLElement>('#knobAndLabel')!;

    type TransitionEventName = 'transitionstart'|'transitionend';
    function whenTransitionEvent(eventName: TransitionEventName):
        Promise<void> {
      return new Promise(resolve => {
        knobAndLabel.addEventListener(
            eventName, function f(e: TransitionEvent) {
              if (e.target !== knobAndLabel) {
                // Ignore any events coming from the paper-ripple or #label.
                return;
              }

              if (e.propertyName !== 'margin-left') {
                // Ignore all other property transitions, as 'margin-inline-end'
                // (surfaced as 'margin-left') is what actually moves the knob
                // along the horizontal axis.
                return;
              }
              knobAndLabel.removeEventListener(eventName, f);
              resolve();
            });
      });
    }

    const whenTransitionStart = whenTransitionEvent('transitionstart');
    const whenTransitionEnd = whenTransitionEvent('transitionend');

    assertNoTransition();
    pointerDown(.5);
    await Promise.all([whenTransitionStart, whenTransitionEnd]);
    assertNoTransition();

    // Other operations that change the value do not have transitions.
    pointerMove(0);
    await microtasksFinished();
    assertNoTransition();
    assertEquals(0, crSlider.value);
    pointerUp();
    pressArrowRight();
    await microtasksFinished();
    assertNoTransition();
    assertEquals(1, crSlider.value);
    crSlider.value = 50;
    await microtasksFinished();
    assertNoTransition();

    // Check that the slider is not stuck with a transition when the value
    // does not change.
    function unexpectedEventListener(e: TransitionEvent) {
      if (e.target !== knobAndLabel) {
        // Ignore any events coming from the paper-ripple or #label.
        return;
      }
      knobAndLabel.removeEventListener(
          e.type as TransitionEventName, unexpectedEventListener);
      assertNotReached(`Unexpected '${e.type}' event for '${e.propertyName}'`);
    }
    knobAndLabel.addEventListener('transitionstart', unexpectedEventListener);
    knobAndLabel.addEventListener('transitionend', unexpectedEventListener);

    // Need to yield here, otherwise for some unknown odd reason the events that
    // would cause this test to fail above are not fired, when they should.
    await new Promise<void>(resolve => window.setTimeout(() => resolve(), 1));

    crSlider.value = 0;
    await microtasksFinished();
    pointerDown(0);

    // Wait a bit to allow for any undesired events to surface.
    await new Promise<void>(resolve => window.setTimeout(() => resolve(), 150));
  });

  test('getRatio()', () => {
    crSlider.min = 1;
    crSlider.max = 11;
    crSlider.value = 1;
    assertEquals(0, crSlider.getRatio());
    crSlider.value = 11;
    assertEquals(1, crSlider.getRatio());
    crSlider.value = 6;
    assertEquals(.5, crSlider.getRatio());
  });

  test('cr-slider-value-changed event when mouse clicked', async () => {
    assertEquals(0, crSlider.value);
    const whenFired = eventToPromise('cr-slider-value-changed', crSlider);
    pointerDown(.1);
    await whenFired;
    assertEquals(10, crSlider.value);
  });

  test('cr-slider-value-changed event when key pressed', async () => {
    assertEquals(0, crSlider.value);
    const whenFired = eventToPromise('cr-slider-value-changed', crSlider);
    pressArrowRight();
    await whenFired;
    assertEquals(1, crSlider.value);
  });

  test(
      'out of range value updated back into min/max range with debounce',
      async () => {
        crSlider.min = -100;
        crSlider.max = 1000;
        crSlider.value = -50;
        await microtasksFinished();
        assertEquals(-50, crSlider.value);

        crSlider.min = 0;
        crSlider.max = 100;
        crSlider.value = 150;
        // Clamping value should happen async, not sync, in order to not race
        // when min/max and value change at the same time.
        assertEquals(150, crSlider.value);

        await microtasksFinished();
        assertEquals(100, crSlider.value);

        crSlider.max = 25;
        await microtasksFinished();
        assertEquals(25, crSlider.value);

        crSlider.min = 50;
        crSlider.max = 100;
        await microtasksFinished();
        assertEquals(50, crSlider.value);
      });

  test('InitialDefaultValue', () => {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    crSlider = document.createElement('cr-slider');
    document.body.appendChild(crSlider);

    assertEquals(0, crSlider.value);
    assertTrue(isVisible(crSlider.$.container));
  });
});