<!DOCTYPE html>
<meta charset="utf-8">
<link rel="author" href="mailto:masonf@chromium.org">
<link rel=help href="https://open-ui.org/components/popover.research.explainer">
<meta name="timeout" content="long">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-actions.js"></script>
<script src="/resources/testdriver-vendor.js"></script>
<script src="resources/popover-utils.js"></script>

<div id=popovers>
  <div popover id=boolean>Popover</div>
  <div popover="">Popover</div>
  <div popover=auto>Popover</div>
  <div popover=hint>Popover</div>
  <div popover=manual>Popover</div>
  <article popover>Different element type</article>
  <header popover>Different element type</header>
  <nav popover>Different element type</nav>
  <input type=text popover value="Different element type">
  <dialog popover>Dialog with popover attribute</dialog>
  <dialog popover="manual">Dialog with popover=manual</dialog>
  <div popover=true>Invalid popover value - defaults to popover=manual</div>
  <div popover=popover>Invalid popover value - defaults to popover=manual</div>
  <div popover=invalid>Invalid popover value - defaults to popover=manual</div>
</div>

<div id=nonpopovers>
  <div>Not a popover</div>
  <dialog open>Dialog without popover attribute</dialog>
</div>

<div id=outside></div>
<style>
[popover] {
  inset:auto;
  top:0;
  left:0;
}
#outside {
  position:fixed;
  top:200px;
  left:200px;
  height:10px;
  width:10px;
}
</style>

<script>
setup({ explicit_done: true });
window.onload = () => {
  const outsideElement = document.getElementById('outside');

  // Start with the provided examples:
  Array.from(document.getElementById('popovers').children).forEach(popover => {
    test((t) => {
      assertIsFunctionalPopover(popover, true);
    }, `The element ${popover.outerHTML} should behave as a popover.`);
  });
  Array.from(document.getElementById('nonpopovers').children).forEach(nonPopover => {
    test((t) => {
      assertNotAPopover(nonPopover);
    }, `The element ${nonPopover.outerHTML} should *not* behave as a popover.`);
  });

  function createPopover(t) {
    const popover = document.createElement('div');
    document.body.appendChild(popover);
    t.add_cleanup(() => popover.remove());
    popover.setAttribute('popover','auto');
    return popover;
  }

  test((t) => {
    // You can set the `popover` attribute to anything.
    // Setting the `popover` IDL to a string sets the content attribute to exactly that, always.
    // Getting the `popover` IDL value only retrieves valid values.
    const popover = createPopover(t);
    assert_equals(popover.popover,'auto');
    popover.setAttribute('popover','auto');
    assert_equals(popover.popover,'auto');
    popover.setAttribute('popover','AuTo');
    assert_equals(popover.popover,'auto','Case is normalized in IDL');
    assert_equals(popover.getAttribute('popover'),'AuTo','Case is *not* normalized/changed in the content attribute');
    popover.popover='aUtO';
    assert_equals(popover.popover,'auto','Case is normalized in IDL');
    assert_equals(popover.getAttribute('popover'),'aUtO','Value set from IDL is propagated exactly to the content attribute');
    popover.setAttribute('popover','invalid');
    assert_equals(popover.popover,'manual','Invalid values should reflect as "manual"');
    popover.removeAttribute('popover');
    assert_equals(popover.popover,null,'No value should reflect as null');
    if (popoverHintSupported()) {
      popover.popover='hint';
      assert_equals(popover.getAttribute('popover'),'hint');
    }
    popover.popover='auto';
    assert_equals(popover.getAttribute('popover'),'auto');
    popover.popover='';
    assert_equals(popover.getAttribute('popover'),'');
    assert_equals(popover.popover,'auto');
    popover.popover='AuTo';
    assert_equals(popover.getAttribute('popover'),'AuTo');
    assert_equals(popover.popover,'auto');
    popover.popover='invalid';
    assert_equals(popover.getAttribute('popover'),'invalid','IDL setter allows any value');
    assert_equals(popover.popover,'manual','but IDL getter reflects "manual"');
    popover.popover='';
    assert_equals(popover.getAttribute('popover'),'','IDL setter propagates exactly');
    assert_equals(popover.popover,'auto','Empty should map to auto in IDL');
    popover.popover='auto';
    popover.popover=null;
    assert_equals(popover.getAttribute('popover'),null,'Setting null for the IDL property should remove the content attribute');
    assert_equals(popover.popover,null,'Null returns null');
    popover.popover='auto';
    popover.popover=undefined;
    assert_equals(popover.getAttribute('popover'),null,'Setting undefined for the IDL property should remove the content attribute');
    assert_equals(popover.popover,null,'undefined returns null');
  },'IDL attribute reflection');

  test((t) => {
    const popover = createPopover(t);
    assertIsFunctionalPopover(popover, true);
    popover.removeAttribute('popover');
    assertNotAPopover(popover);
    popover.setAttribute('popover','AuTo');
    assertIsFunctionalPopover(popover, true);
    popover.removeAttribute('popover');
    popover.setAttribute('PoPoVeR','AuTo');
    assertIsFunctionalPopover(popover, true);
    // Via IDL also
    popover.popover = 'auto';
    assertIsFunctionalPopover(popover, true);
    popover.popover = 'aUtO';
    assertIsFunctionalPopover(popover, true);
    popover.popover = 'invalid'; // treated as "manual"
    assertIsFunctionalPopover(popover, true);
  },'Popover attribute value should be case insensitive');

  test((t) => {
    const popover = createPopover(t);
    assertIsFunctionalPopover(popover, true);
    popover.setAttribute('popover','manual'); // Change popover type
    assertIsFunctionalPopover(popover, true);
    popover.setAttribute('popover','invalid'); // Change popover type to something invalid
    assertIsFunctionalPopover(popover, true);
    popover.popover = 'manual'; // Change popover type via IDL
    assertIsFunctionalPopover(popover, true);
    popover.popover = 'invalid'; // Make invalid via IDL (treated as "manual")
    assertIsFunctionalPopover(popover, true);
  },'Changing attribute values for popover should work');

  test((t) => {
    const popover = createPopover(t);
    popover.showPopover();
    assert_true(popover.matches(':popover-open'));
    if (popoverHintSupported()) {
      popover.setAttribute('popover','hint'); // Change popover type
      assert_false(popover.matches(':popover-open'));
      popover.showPopover();
      assert_true(popover.matches(':popover-open'));
    }
    popover.setAttribute('popover','manual');
    assert_false(popover.matches(':popover-open'));
    popover.showPopover();
    assert_true(popover.matches(':popover-open'));
    popover.setAttribute('popover','invalid');
    assert_true(popover.matches(':popover-open'),'From "manual" to "invalid" (which is interpreted as "manual") should not close the popover');
    popover.setAttribute('popover','auto');
    assert_false(popover.matches(':popover-open'),'From "invalid" ("manual") to "auto" should hide the popover');
    popover.showPopover();
    assert_true(popover.matches(':popover-open'));
    popover.setAttribute('popover','invalid');
    assert_false(popover.matches(':popover-open'),'From "auto" to "invalid" (which is interpreted as "manual") should close the popover');
  },'Changing attribute values should close open popovers');

  const validTypes = popoverHintSupported() ? ["auto","hint","manual"] : ["auto","manual"];
  validTypes.forEach(type => {
    test((t) => {
      const popover = createPopover(t);
      popover.setAttribute('popover',type);
      popover.showPopover();
      assert_true(popover.matches(':popover-open'));
      popover.remove();
      assert_false(popover.matches(':popover-open'));
      document.body.appendChild(popover);
      assert_false(popover.matches(':popover-open'));
    },`Removing a visible popover=${type} element from the document should close the popover`);

    test((t) => {
      const popover = createPopover(t);
      popover.setAttribute('popover',type);
      popover.showPopover();
      assert_true(popover.matches(':popover-open'));
      assert_false(popover.matches(':modal'));
      popover.hidePopover();
    },`A showing popover=${type} does not match :modal`);

    test((t) => {
      const popover = createPopover(t);
      popover.setAttribute('popover',type);
      assert_false(popover.matches(':popover-open'));
      // FIXME: Once :open/:closed are defined in HTML we should remove these two constants.
      const openPseudoClassIsSupported = CSS.supports('selector(:open))');
      const closePseudoClassIsSupported = CSS.supports('selector(:closed))');
      assert_false(openPseudoClassIsSupported && popover.matches(':open'),'popovers never match :open');
      assert_false(closePseudoClassIsSupported && popover.matches(':closed'),'popovers never match :closed');
      popover.showPopover();
      assert_true(popover.matches(':popover-open'));
      assert_false(openPseudoClassIsSupported && popover.matches(':open'),'popovers never match :open');
      assert_false(closePseudoClassIsSupported && popover.matches(':closed'),'popovers never match :closed');
      popover.hidePopover();
    },`A popover=${type} never matches :open or :closed`);
  });

  test((t) => {
    const other_popover = createPopover(t);
    other_popover.setAttribute('popover','auto');
    other_popover.showPopover();
    const popover = createPopover(t);
    popover.setAttribute('popover','auto');
    other_popover.addEventListener('beforetoggle', (e) => {
      if (e.newState !== "closed")
        return;
      popover.setAttribute('popover','manual');
    },{once: true});
    assert_true(other_popover.matches(':popover-open'));
    assert_false(popover.matches(':popover-open'));
    assert_throws_dom('InvalidStateError', () => popover.showPopover());
    assert_false(other_popover.matches(':popover-open'),'unrelated popover is hidden');
    assert_false(popover.matches(':popover-open'),'popover is not shown if its type changed during show');
  },`Changing the popover type in a "beforetoggle" event handler should throw an exception (during showPopover())`);

  test((t) => {
    const popover = createPopover(t);
    popover.setAttribute('popover','auto');
    const other_popover = createPopover(t);
    other_popover.setAttribute('popover','auto');
    popover.appendChild(other_popover);
    popover.showPopover();
    other_popover.showPopover();
    let nested_popover_hidden=false;
    other_popover.addEventListener('beforetoggle', (e) => {
      if (e.newState !== "closed")
        return;
      nested_popover_hidden = true;
      popover.setAttribute('popover','manual');
    },{once: true});
    popover.addEventListener('beforetoggle', (e) => {
      if (e.newState !== "closed")
        return;
      assert_true(nested_popover_hidden,'The nested popover should be hidden first');
    },{once: true});
    assert_true(popover.matches(':popover-open'));
    assert_true(other_popover.matches(':popover-open'));
    popover.hidePopover(); // Calling hidePopover on a hidden popover should not throw.
    assert_false(other_popover.matches(':popover-open'),'unrelated popover is hidden');
    assert_false(popover.matches(':popover-open'),'popover is still hidden if its type changed during hide event');
    other_popover.hidePopover(); // Calling hidePopover on a hidden popover should not throw.
  },`Changing the popover type in a "beforetoggle" event handler during hidePopover() should not throw an exception`);

  test(t => {
    const popover = document.createElement('div');
    assert_throws_dom('NotSupportedError', () => popover.hidePopover(),
      'Calling hidePopover on an element without a popover attribute should throw.');
    popover.setAttribute('popover', 'auto');
    popover.hidePopover(); // Calling hidePopover on a disconnected popover should not throw.
    assert_throws_dom('InvalidStateError', () => popover.showPopover(),
      'Calling showPopover on a disconnected popover should throw.');
  },'Calling hidePopover on a disconnected popover should not throw.');

  function interpretedType(typeString,method) {
    if (validTypes.includes(typeString))
      return typeString;
    if (typeString === undefined)
      return "invalid-value-undefined";
    if (method === "idl" && typeString === null)
      return "invalid-value-idl-null";
    return "manual"; // Invalid types default to "manual"
  }
  function setPopoverValue(popover,type,method) {
    switch (method) {
      case "attr":
        if (type === undefined) {
          popover.removeAttribute('popover');
        } else {
          popover.setAttribute('popover',type);
        }
        break;
      case "idl":
        popover.popover = type;
        break;
      default:
        assert_notreached();
    }
  }
  ["attr","idl"].forEach(method => {
    validTypes.forEach(type => {
      [...validTypes,"invalid",null,undefined].forEach(newType => {
        [...validTypes,"invalid",null,undefined].forEach(inEventType => {
          promise_test(async (t) => {
            const popover = createPopover(t);
            setPopoverValue(popover,type,method);
            popover.showPopover();
            assert_true(popover.matches(':popover-open'));
            let gotEvent = false;
            popover.addEventListener('beforetoggle', (e) => {
              if (e.newState !== "closed")
                return;
              gotEvent = true;
              setPopoverValue(popover,inEventType,method);
            },{once:true});
            setPopoverValue(popover,newType,method);
            if (type===interpretedType(newType,method)) {
              // Keeping the type the same should not hide it or fire events.
              assert_true(popover.matches(':popover-open'),'popover should remain open when not changing the type');
              assert_false(gotEvent);
              try {
                popover.hidePopover(); // Cleanup
              } catch (e) {}
            } else {
              // Changing the type at all should hide the popover. The hide event
              // handler should run, set a new type, and that type should end up
              // as the final result.
              assert_false(popover.matches(':popover-open'));
              if (inEventType === undefined || (method ==="idl" && inEventType === null)) {
                assert_throws_dom("NotSupportedError",() => popover.showPopover(),'We should have removed the popover attribute, so showPopover should throw');
              } else {
                // Make sure the attribute is correct.
                assert_equals(popover.getAttribute('popover'),String(inEventType),'Content attribute');
                assert_equals(popover.popover, interpretedType(inEventType,method),'IDL attribute');
                // Make sure the type is really correct, via behavior.
                popover.showPopover(); // Show it
                assert_true(popover.matches(':popover-open'),'Popover should function');
                await clickOn(outsideElement); // Try to light dismiss
                switch (interpretedType(inEventType,method)) {
                  case 'manual':
                    assert_true(popover.matches(':popover-open'),'A popover=manual should not light-dismiss');
                    popover.hidePopover();
                    break;
                  case 'auto':
                  case 'hint':
                    assert_false(popover.matches(':popover-open'),'A popover=auto should light-dismiss');
                    break;
                }
              }
            }
          },`Changing a popover from ${type} to ${newType} (via ${method}), and then ${inEventType} during 'beforetoggle' works`);
        });
      });
    });
  });

  done();
};
</script>
