<!DOCTYPE HTML>
<meta charset="UTF-8">
<title>CSS Toggles: ARIA roles and inferred keyboard handling</title>
<link rel="author" title="L. David Baron" href="https://dbaron.org/">
<link rel="author" title="Google" href="http://www.google.com/">
<link rel="help" href="https://tabatkins.github.io/css-toggle/">
<link rel="help" href="https://github.com/tabatkins/css-toggle/issues/41">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-vendor.js"></script>
<script src="support/toggle-helpers.js"></script>
<style>

/* for send_keys */
div { min-height: 10px }

</style>

<body>

<div id="container"></div>
<script>

let aria_role_tests = [
  // Markup to create the test assertions:
  //   data-expected-role:  The expected aria role for this element.
  //   data-expected-trigger-keys:  When present, indicates that keyboard events
  //     should be tested on this element, and indicates the keys
  //     (space-separated) that are expected to toggle the toggle.
  //   data-expected-arrows-between-children:  When present, indicates
  //     that arrow keys should navigate between the children of this
  //     element that have the given role.
  //
  // Helper markup to create more markup:
  //   class=group: group the group with the toggle-group property
  //   class=group-self: same, but with the self keyword (narrow scope)
  //   class=root: create a test-role toggle with the toggle-root property
  //   class=root-group: same, but with the 'group' keyword
  //   class=root-self: same, but with the 'self' keyword
  //   class=trigger: toggle-trigger to activate test-role toggle
  //   class=visibility: toggle-visibility connected to test-role toggle
  `
    <div></div>
  `,
  `
    <div class="root">
      <div></div>
    </div>
  `,
  `
    <div class="root trigger" data-expected-role="checkbox"></div>
  `,
  // Test that ARIA attributes override the toggle inference:
  `
    <div class="root trigger" role="link" data-expected-role="link"></div>
  `,
  `
    <div class="root">
      <div class="trigger" data-expected-role="button"></div>
    </div>
  `,

  // Radios and radio groups:
  `
    <div class="group" data-expected-role="radiogroup">
      <div class="root-group trigger" data-expected-role="radio" data-expected-trigger-keys="Space"></div>
    </div>
  `,
  `
    <div class="group" data-expected-role="radiogroup" data-expected-arrows-between-children="radio">
      <div class="root-group trigger" data-expected-role="radio" data-expected-trigger-keys="Space"></div>
      <div class="root-group trigger" data-expected-role="radio" data-expected-trigger-keys="Space"></div>
    </div>
  `,
  `
    <div>
      <div class="root-group trigger" data-expected-role="radio"></div>
    </div>
  `,
  `
    <div style="toggle-group: another-group">
      <div class="root-group trigger" data-expected-role="radio"></div>
    </div>
  `,
  `
    <div style="toggle-group: another-group, test-role, third-group" data-expected-role="radiogroup">
      <div class="root-group trigger" data-expected-role="radio"></div>
    </div>
  `,


  // Checkboxes and checkbox groups:
  `
    <div>
      <div class="root trigger" data-expected-role="checkbox" data-expected-trigger-keys="Space"></div>
    </div>
  `,
  // TODO(https://crbug.com/1250716): This is a checkbox group... but we
  // can't distinguish that with current ARIA roles.
  `
    <div data-expected-arrows-between-children="checkbox">
      <div class="root trigger" data-expected-role="checkbox" data-expected-trigger-keys="Space"></div>
      <div class="root trigger" data-expected-role="checkbox" data-expected-trigger-keys="Space"></div>
    </div>
  `,

  // Disclosure:
  // TODO(https://crbug.com/1250716): This is a disclosure... but how is
  // it possible to distinguish with ARIA roles (compare to next test!)?
  `
    <div class="root">
      <div class="trigger" data-expected-role="button" data-expected-trigger-keys="Space Enter"></div>
      <div class="visibility"></div>
    </div>
  `,
  // This is not a disclosure because it has a toggle-group.
  `
    <div class="root-group">
      <div class="trigger" data-expected-role="button" data-expected-trigger-keys="Space Enter"></div>
      <div class="visibility"></div>
    </div>
  `,
  // This is button with popup (absolute positioning)
  // TODO(https://crbug.com/1250716): This test doesn't actually
  // distinguish this from disclosure because the internal kPopUpButton
  // role maps to "button" in kReverseRoles in ax_object.cc.
  `
    <div class="root">
      <div class="trigger" data-expected-role="button"></div>
      <div class="visibility" style="position: absolute"></div>
    </div>
  `,
  // This is button with popup (fixed positioning)
  // TODO(https://crbug.com/1250716): This test doesn't actually
  // distinguish this from disclosure because the internal kPopUpButton
  // role maps to "button" in kReverseRoles in ax_object.cc.
  `
    <div class="root">
      <div class="trigger" data-expected-role="button" data-expected-trigger-keys="Space Enter"></div>
      <div class="visibility" style="position: fixed"></div>
    </div>
  `,
  // This is button with popup (popover)
  // TODO(https://crbug.com/1250716): This test doesn't actually
  // distinguish this from disclosure because the internal kPopUpButton
  // role maps to "button" in kReverseRoles in ax_object.cc.
  `
    <div class="root">
      <div class="trigger" data-expected-role="button" data-expected-trigger-keys="Space Enter"></div>
      <div class="visibility" popover="auto"></div>
    </div>
  `,
  // This is disclosure (NOT button with popup) (sticky positioning)
  `
    <div class="root">
      <div class="trigger" data-expected-role="button" data-expected-trigger-keys="Space Enter"></div>
      <div class="visibility" style="position: sticky"></div>
    </div>
  `,

  // Accordion:
  `
    <div class="group">
      <div class="root-group" data-expected-role="region">
        <div class="trigger" data-expected-role="button"></div>
        <div class="visibility"></div>
      </div>
      <div class="root-group" data-expected-role="region">
        <div class="trigger" data-expected-role="button"></div>
        <div class="visibility"></div>
      </div>
    </div>
  `,
  // Not accordion because of other siblings:
  `
    <div class="group">
      <div class="root-group">
        <div class="trigger" data-expected-role="button"></div>
        <div class="visibility"></div>
      </div>
      <div class="root-group">
        <div class="trigger" data-expected-role="button"></div>
        <div class="visibility"></div>
      </div>
      <div></div>
      <div></div>
      <div></div>
    </div>
  `,

  // Tree:
  // TODO(https://crbug.com/1250716): This should probably also work
  // with the toggles on the <button>!
  // TODO(https://crbug.com/1250716): This should probably mark the
  // non-interactive items as treeitem as well!
  // TODO(https://crbug.com/1250716): Do the elements getting the roles
  // here make sense?
  // TODO(https://crbug.com/1250716): The requirement for having
  // multiple disclosure-ish children to qualify as accordion-ish
  // probably doesn't make sense here.  The test below is basically the
  // minimal example that gets detected as a tree, but simpler things
  // definitely should be!
  // TODO(https://crbug.com/1250716): The inner parts of the tree should
  // also be getting tree roles!
  `
    <ul data-expected-role="tree">
      <li class="root-self" data-expected-role="group">
        <button class="trigger" data-expected-role="treeitem"></button>
        <ul class="visibility" data-expected-role="list">
          <li>item</li>
          <li class="root-self">
            <button class="trigger" data-expected-role="button"></button>
            <ul class="visibility" data-expected-role="list">
              <li>item</li>
              <li>item</li>
            </ul>
          </li>
          <li class="root-self">
            <button class="trigger" data-expected-role="button"></button>
            <ul class="visibility" data-expected-role="list">
              <li>item</li>
              <li>item</li>
            </ul>
          </li>
        </ul>
      </li>
      <li class="root-self" data-expected-role="group">
        <button class="trigger" data-expected-role="treeitem"></button>
        <ul class="visibility" data-expected-role="list">
          <li class="root-self">
            <button class="trigger" data-expected-role="button"></button>
            <ul class="visibility" data-expected-role="list">
              <li>item</li>
              <li>item</li>
            </ul>
          </li>
          <li class="root-self">
            <button class="trigger" data-expected-role="button"></button>
            <ul class="visibility" data-expected-role="list">
              <li>item</li>
              <li>item</li>
            </ul>
          </li>
        </ul>
      </li>
    </ul>
  `,

  // Tabs:
  `
    <section class="group" data-expected-role="tablist" data-expected-arrows-between-children="tab">
      <h1 class="root-group trigger" data-expected-role="tab" data-expected-trigger-keys="Space Enter"></h1>
      <div class="visibility" data-expected-role="tabpanel"></div>
      <h1 class="root-group trigger" data-expected-role="tab" data-expected-trigger-keys="Space Enter"></h1>
      <div class="visibility" data-expected-role="tabpanel"></div>
      <h1 class="root-group trigger" data-expected-role="tab" data-expected-trigger-keys="Space Enter"></h1>
      <div class="visibility" data-expected-role="tabpanel"></div>
    </section>
  `,
  `
    <section class="group" data-expected-role="tablist">
      <h1 class="root-group trigger" data-expected-role="tab"></h1>
      <div class="visibility" data-expected-role="tabpanel"></div>
      <h1 class="root-group trigger" data-expected-role="tab"></h1>
      <div class="visibility" data-expected-role="tabpanel"></div>
      <div></div>
    </section>
  `,
  `
    <section class="group" data-expected-role="tablist">
      <h1 class="root-group trigger" data-expected-role="tab"></h1>
      <div class="visibility" data-expected-role="tabpanel"></div>
      <h1 class="root-group trigger" data-expected-role="tab"></h1>
      <div class="visibility" data-expected-role="tabpanel"></div>
      <h1 style="toggle-root: other-toggle; toggle-trigger: other-toggle" data-expected-role="checkbox"></h1>
    </section>
  `,
  `
    <section class="group" data-expected-role="tablist">
      <h1 class="root-group trigger" data-expected-role="tab"></h1>
      <div class="visibility" data-expected-role="tabpanel"></div>
      <h1 class="root-group trigger" data-expected-role="tab"></h1>
      <div class="visibility" data-expected-role="tabpanel"></div>
      <h1 style="toggle-root: other-toggle; toggle-trigger: other-toggle" data-expected-role="button"></h1>
      <div style="toggle-visibility: toggle other-toggle"></div>
    </section>
  `,
  // TODO(https://crbug.com/758089): The expected role for the <section>
  // should be generic rather than null!
  `
    <section class="group" data-expected-role="null">
      <h1 class="root-group trigger" data-expected-role="button"></h1>
      <div class="visibility"></div>
      <h1 class="root-group trigger" data-expected-role="button"></h1>
      <div class="visibility"></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
    </section>
  `,
  `
    <section class="group" data-expected-role="radiogroup">
      <h1 class="root-group trigger" data-expected-role="radio"></h1>
      <h1 class="root-group trigger" data-expected-role="radio"></h1>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
    </section>
  `,
];

function find_toggle_in_scope(e) {
  let allow_self = true;
  while (e) {
    let toggle = e.toggles.get("test-role");
    if (toggle && (allow_self || toggle.scope == "wide"))
      return toggle;
    let sibling = e.previousElementSibling;
    if (sibling) {
      e = sibling;
      allow_self = false;
    }
    e = e.parentNode;
    allow_self = true;
  }
  return null;
}

for (let t of aria_role_tests) {
  promise_test(async function() {
    container.innerHTML = t;

    for (let e of container.querySelectorAll('.group')) {
      e.style.toggleGroup = "test-role";
    }
    for (let e of container.querySelectorAll('.group-self')) {
      e.style.toggleGroup = "test-role self";
    }
    for (let e of container.querySelectorAll('.root')) {
      e.style.toggleRoot = "test-role";
    }
    for (let e of container.querySelectorAll('.root-group')) {
      e.style.toggleRoot = "test-role group";
    }
    for (let e of container.querySelectorAll('.root-self')) {
      e.style.toggleRoot = "test-role self";
    }
    for (let e of container.querySelectorAll('.trigger')) {
      e.style.toggleTrigger = "test-role";
    }
    for (let e of container.querySelectorAll('.visibility')) {
      e.style.toggleVisibility = "toggle test-role";
    }

    for (let e of container.querySelectorAll('.root, .root-nogroup')) {
      await wait_for_toggle_creation(e);
    }

    let count = 0;
    for (let e of container.querySelectorAll("*")) {
      if (e == container)
        continue;

      let expected_role = "generic";
      if (e.hasAttribute("data-expected-role")) {
        expected_role = e.getAttribute("data-expected-role");
        // TODO(https://crbug.com/758089): See above regarding <section>;
        // this null handling should eventually be removed.
        if (expected_role === "null") {
          expected_role = null;
        }
      }
      ++count;
      // NOTE:  This relies on Element.computedRole, which is an
      // experimental feature behind the ComputedAccessibilityInfo flag
      // in blink.
      assert_equals(e.computedRole, expected_role, `role on ${e.tagName} element (#${count})`);
    }

    if (container.querySelector("[data-expected-arrows-between-children], [data-expected-trigger-keys]")) {
      // We should do keyboard tests for this test.
      for (let e of container.querySelectorAll("*")) {
        if (e == container)
          continue;

        let arrows = e.parentNode.hasAttribute("data-expected-arrows-between-children") && e.getAttribute("data-expected-role") == e.parentNode.getAttribute("data-expected-arrows-between-children");
        let trigger_keys = [];
        if (e.hasAttribute("data-expected-trigger-keys")) {
          trigger_keys = e.getAttribute("data-expected-trigger-keys").split(" ");
        }

        if (!e.classList.contains("trigger")) {
          // It is a bug in the test to have expected key handling for elements
          // that are not toggle triggers.
          assert_false(arrows);
          assert_equals(trigger_keys.length, 0);
          continue;
        }

        // Test handling of space, enter, and all arrows, and check that
        // it matches expectations.
        let keys_to_test = {
          "Enter": "\uE007",
          // better known as " ", but "Space" in this test.
          // https://w3c.github.io/webdriver/#keyboard-actions says
          // "\uE00D" but that doesn't work.
          "Space": " ",
          "ArrowLeft": "\uE012",
          "ArrowUp": "\uE013",
          "ArrowRight": "\uE014",
          "ArrowDown": "\uE015",
        };
        for (let key in keys_to_test) {
          let toggle = find_toggle_in_scope(e);
          let expected_state = toggle.valueAsNumber;
          e.focus();
          let expected_focus = e;
          assert_equals(document.activeElement, expected_focus, `focus before ${key} key`);
          await test_driver.send_keys(document.body, keys_to_test[key]);
          if (trigger_keys.includes(key)) {
            expected_state = expected_state ? 0 : 1;
          }
          if (key.startsWith("Arrow")) {
            if (arrows) {
              let role = e.parentNode.getAttribute("data-expected-arrows-between-children");
              let direction;
              if (key == "ArrowLeft" || key == "ArrowUp") {
                direction = "previousElementSibling";
              } else {
                direction = "nextElementSibling";
              }
              let new_element = e;
              while ((new_element = new_element[direction])) {
                if (new_element.getAttribute("data-expected-role") == role)
                  break;
              }
              if (new_element)
                expected_focus = new_element;
            }
          }
          assert_equals(toggle.valueAsNumber, expected_state, `state after ${key} key`);
          assert_equals(document.activeElement, expected_focus, `focus after ${key} key`);
        }
      }
    }

  }, `aria role and key handling test: ${t}`);
}

</script>
