@livetemplate/client 0.11.4 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -648,4 +648,948 @@ describe("handleAutoClickDirectives", () => {
648
648
  expect(clickSpy).not.toHaveBeenCalled();
649
649
  });
650
650
  });
651
+ describe("handleShadowRootHydration", () => {
652
+ beforeEach(() => {
653
+ document.body.innerHTML = "";
654
+ });
655
+ it("attaches an open shadow root and moves template content into it", () => {
656
+ document.body.innerHTML = `
657
+ <div id="host">
658
+ <template shadowrootmode="open"><span class="inner">hi</span></template>
659
+ </div>
660
+ `;
661
+ const host = document.getElementById("host");
662
+ (0, directives_1.handleShadowRootHydration)(document.body);
663
+ expect(host.shadowRoot).not.toBeNull();
664
+ expect(host.shadowRoot.querySelector(".inner")?.textContent).toBe("hi");
665
+ // Template should be gone — leaving it would re-trigger the hook
666
+ // on every subsequent render.
667
+ expect(host.querySelector("template")).toBeNull();
668
+ });
669
+ it("honors shadowrootmode='closed'", () => {
670
+ document.body.innerHTML = `
671
+ <div id="host">
672
+ <template shadowrootmode="closed"><span>x</span></template>
673
+ </div>
674
+ `;
675
+ const host = document.getElementById("host");
676
+ (0, directives_1.handleShadowRootHydration)(document.body);
677
+ // Closed shadow root: parent.shadowRoot stays null per spec, but
678
+ // the template still gets consumed.
679
+ expect(host.shadowRoot).toBeNull();
680
+ expect(host.querySelector("template")).toBeNull();
681
+ });
682
+ it("is a no-op when there are no shadowroot templates", () => {
683
+ document.body.innerHTML = `<div><p>nothing here</p></div>`;
684
+ const before = document.body.innerHTML;
685
+ (0, directives_1.handleShadowRootHydration)(document.body);
686
+ expect(document.body.innerHTML).toBe(before);
687
+ });
688
+ it("replaces existing shadow content on re-hydration in closed mode (WeakMap fallback)", () => {
689
+ // parent.shadowRoot is null for closed roots by spec, so a re-render
690
+ // would otherwise re-call attachShadow, throw NotSupportedError, and
691
+ // silently drop the new content. The WeakMap side-channel locates
692
+ // the prior root so replaceChildren actually updates it.
693
+ document.body.innerHTML = `
694
+ <div id="host">
695
+ <template shadowrootmode="closed"><span class="round">1</span></template>
696
+ </div>
697
+ `;
698
+ const host = document.getElementById("host");
699
+ (0, directives_1.handleShadowRootHydration)(document.body);
700
+ expect(host.shadowRoot).toBeNull(); // closed — spec confirms
701
+ // The directive's WeakMap holds the closed root; we can't observe
702
+ // its content via host.shadowRoot, but we CAN verify (a) the
703
+ // re-render path doesn't throw, (b) the template gets consumed,
704
+ // and (c) the template's content left the light DOM — together,
705
+ // strong evidence the content moved into the cached shadow root
706
+ // rather than vanishing or staying parked as an inert template
707
+ // (the exact failure mode pre-fix).
708
+ host.innerHTML = `<template shadowrootmode="closed"><span class="round">2</span></template>`;
709
+ expect(() => (0, directives_1.handleShadowRootHydration)(document.body)).not.toThrow();
710
+ expect(host.querySelector("template")).toBeNull();
711
+ expect(host.children.length).toBe(0); // content lives in shadow, not light DOM
712
+ });
713
+ it("replaces existing shadow content on re-hydration (server re-render)", () => {
714
+ document.body.innerHTML = `
715
+ <div id="host">
716
+ <template shadowrootmode="open"><span>first</span></template>
717
+ </div>
718
+ `;
719
+ const host = document.getElementById("host");
720
+ (0, directives_1.handleShadowRootHydration)(document.body);
721
+ expect(host.shadowRoot.querySelector("span")?.textContent).toBe("first");
722
+ // Simulate the server emitting a new template into the same host
723
+ // after a morph (host kept, template re-inserted).
724
+ host.innerHTML = `<template shadowrootmode="open"><span>second</span></template>`;
725
+ (0, directives_1.handleShadowRootHydration)(document.body);
726
+ expect(host.shadowRoot.querySelector("span")?.textContent).toBe("second");
727
+ expect(host.querySelector("template")).toBeNull();
728
+ });
729
+ it("handles multiple sibling templates on one page", () => {
730
+ document.body.innerHTML = `
731
+ <div id="a"><template shadowrootmode="open"><i>A</i></template></div>
732
+ <div id="b"><template shadowrootmode="open"><i>B</i></template></div>
733
+ <div id="c"><template shadowrootmode="open"><i>C</i></template></div>
734
+ `;
735
+ (0, directives_1.handleShadowRootHydration)(document.body);
736
+ for (const [id, want] of [["a", "A"], ["b", "B"], ["c", "C"]]) {
737
+ const host = document.getElementById(id);
738
+ expect(host.shadowRoot?.querySelector("i")?.textContent).toBe(want);
739
+ }
740
+ });
741
+ it("silently drops the template when the host can't accept a shadow root", () => {
742
+ // Real-world hosts that can't accept a shadow root (void elements,
743
+ // <input>, <textarea>, custom-element mode conflicts) make
744
+ // attachShadow throw a DOMException. The hook must catch and
745
+ // remove the template instead of leaving a re-trigger ticking on
746
+ // every render. Drive the failure path via a stub on a regular
747
+ // <div> so this test doesn't depend on jsdom's <input>-as-host
748
+ // behaviour, which varies across versions.
749
+ const host = document.createElement("div");
750
+ host.id = "host";
751
+ document.body.appendChild(host);
752
+ const tpl = document.createElement("template");
753
+ tpl.setAttribute("shadowrootmode", "open");
754
+ host.appendChild(tpl);
755
+ const orig = host.attachShadow.bind(host);
756
+ host.attachShadow = () => {
757
+ throw new DOMException("Operation is not supported", "NotSupportedError");
758
+ };
759
+ expect(() => (0, directives_1.handleShadowRootHydration)(document.body)).not.toThrow();
760
+ // Template must be removed even though attach failed.
761
+ expect(host.querySelector("template")).toBeNull();
762
+ expect(host.shadowRoot).toBeNull();
763
+ host.attachShadow = orig;
764
+ host.remove();
765
+ });
766
+ it("warns when attachShadow rejects the host (DOMException path)", () => {
767
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
768
+ const host = document.createElement("div");
769
+ host.id = "warn-host";
770
+ document.body.appendChild(host);
771
+ const tpl = document.createElement("template");
772
+ tpl.setAttribute("shadowrootmode", "open");
773
+ host.appendChild(tpl);
774
+ host.attachShadow = () => {
775
+ throw new DOMException("Operation is not supported", "NotSupportedError");
776
+ };
777
+ (0, directives_1.handleShadowRootHydration)(document.body);
778
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("attachShadow rejected"), host);
779
+ warn.mockRestore();
780
+ host.remove();
781
+ });
782
+ it("removes templates with an unrecognised shadowrootmode and warns", () => {
783
+ // The HTML parser doesn't activate a `<template shadowrootmode>`
784
+ // with an unknown mode value. The directive removes the template
785
+ // outright (so the fast-path advertised in the docblock isn't
786
+ // defeated by a persistent typo that the qsa keeps re-finding) and
787
+ // logs a console.warn so authors actually see the mistake.
788
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
789
+ document.body.innerHTML = `
790
+ <div id="host">
791
+ <template shadowrootmode="opne"><span>typo</span></template>
792
+ </div>
793
+ `;
794
+ const host = document.getElementById("host");
795
+ (0, directives_1.handleShadowRootHydration)(document.body);
796
+ expect(host.shadowRoot).toBeNull();
797
+ expect(host.querySelector("template")).toBeNull();
798
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("invalid shadowrootmode"), expect.anything());
799
+ warn.mockRestore();
800
+ });
801
+ it("rethrows non-DOMException errors so real bugs surface", () => {
802
+ document.body.innerHTML = `
803
+ <div id="host"><template shadowrootmode="open"><span>x</span></template></div>
804
+ `;
805
+ const host = document.getElementById("host");
806
+ host.attachShadow = () => {
807
+ throw new Error("typo in options or runtime bug");
808
+ };
809
+ // A bare catch would have hidden this; the narrow guard surfaces it.
810
+ expect(() => (0, directives_1.handleShadowRootHydration)(document.body)).toThrow("typo in options or runtime bug");
811
+ });
812
+ it("idempotent re-run when no remaining templates is essentially free", () => {
813
+ document.body.innerHTML = `<div id="host"></div>`;
814
+ // First run: nothing to do.
815
+ (0, directives_1.handleShadowRootHydration)(document.body);
816
+ // Second run on a clean tree: still nothing.
817
+ (0, directives_1.handleShadowRootHydration)(document.body);
818
+ const host = document.getElementById("host");
819
+ expect(host.shadowRoot).toBeNull();
820
+ });
821
+ it("forwards shadowrootdelegatesfocus / clonable / serializable to attachShadow", () => {
822
+ // Use a spy because jsdom doesn't expose the options on the resulting
823
+ // ShadowRoot in a stable way across versions — checking the call args
824
+ // is what we actually want to assert ("the directive forwards
825
+ // attributes faithfully to the platform call").
826
+ document.body.innerHTML = `
827
+ <div id="host">
828
+ <template shadowrootmode="open" shadowrootdelegatesfocus shadowrootclonable shadowrootserializable>
829
+ <span>focus me</span>
830
+ </template>
831
+ </div>
832
+ `;
833
+ const host = document.getElementById("host");
834
+ const spy = jest.spyOn(host, "attachShadow");
835
+ (0, directives_1.handleShadowRootHydration)(document.body);
836
+ expect(spy).toHaveBeenCalledWith({
837
+ mode: "open",
838
+ delegatesFocus: true,
839
+ clonable: true,
840
+ serializable: true,
841
+ });
842
+ });
843
+ // Documented limitations — kept as it.skip so a future PR that
844
+ // closes either gap can flip the skip and have an instant test.
845
+ it("warns when shadowrootmode is changed on re-render (mode is one-shot)", () => {
846
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
847
+ document.body.innerHTML = `
848
+ <div id="host">
849
+ <template shadowrootmode="open"><span>first</span></template>
850
+ </div>
851
+ `;
852
+ const host = document.getElementById("host");
853
+ (0, directives_1.handleShadowRootHydration)(document.body);
854
+ // Server flips the mode on the next render — surfacing the mismatch
855
+ // is the contract.
856
+ host.innerHTML = `<template shadowrootmode="closed"><span>second</span></template>`;
857
+ (0, directives_1.handleShadowRootHydration)(document.body);
858
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("shadowrootmode changed"), host);
859
+ // The pre-existing open root persists (attachShadow can't be re-
860
+ // called); content still updates inside it.
861
+ expect(host.shadowRoot?.querySelector("span")?.textContent).toBe("second");
862
+ warn.mockRestore();
863
+ });
864
+ it.skip("nested DSD inside another template is inert on first render", () => {
865
+ // A `<template shadowrootmode>` nested inside another `<template>`
866
+ // is in DocumentFragment land, which qsa doesn't descend into — the
867
+ // inner template is never in the first qsa result, so it stays
868
+ // inert from render zero. (The "on re-render" case is the same
869
+ // mechanism: after the outer shadow attaches, the inner template
870
+ // sits behind a shadow boundary, also out of qsa's reach.)
871
+ document.body.innerHTML = `
872
+ <div id="outer">
873
+ <template shadowrootmode="open">
874
+ <div id="inner">
875
+ <template shadowrootmode="open"><span>nested</span></template>
876
+ </div>
877
+ </template>
878
+ </div>
879
+ `;
880
+ (0, directives_1.handleShadowRootHydration)(document.body);
881
+ const outer = document.getElementById("outer");
882
+ const inner = outer.shadowRoot.getElementById("inner");
883
+ expect(inner.shadowRoot).toBeNull();
884
+ expect(inner.querySelector("template")).not.toBeNull();
885
+ });
886
+ it("defaults all extended options to false when attrs are absent", () => {
887
+ document.body.innerHTML = `
888
+ <div id="host">
889
+ <template shadowrootmode="open"><span>x</span></template>
890
+ </div>
891
+ `;
892
+ const host = document.getElementById("host");
893
+ const spy = jest.spyOn(host, "attachShadow");
894
+ (0, directives_1.handleShadowRootHydration)(document.body);
895
+ expect(spy).toHaveBeenCalledWith({
896
+ mode: "open",
897
+ delegatesFocus: false,
898
+ clonable: false,
899
+ serializable: false,
900
+ });
901
+ });
902
+ });
903
+ describe("handleAreaSelectDirectives", () => {
904
+ // The module-level areaSelectArmed map is cleared lazily — the sweep
905
+ // only fires when handleAreaSelectDirectives runs. Without an explicit
906
+ // afterEach, tests that don't call it would inherit armed elements
907
+ // from prior tests. teardownAreaSelectForRoot(document.body) wipes
908
+ // every entry so each test gets a fresh slate.
909
+ afterEach(() => {
910
+ (0, directives_1.teardownAreaSelectForRoot)(document.body);
911
+ });
912
+ // jsdom-friendly helper: configure the target element so it has a
913
+ // non-trivial bounding rect (jsdom returns zeros by default) and a
914
+ // positioned parent so the overlay has somewhere to land. Returns
915
+ // [target, parent] for the assertions.
916
+ function mountTarget(targetTag, attrs, rect) {
917
+ document.body.innerHTML = `
918
+ <div id="parent" style="position:relative;">
919
+ <${targetTag} id="target"></${targetTag}>
920
+ </div>
921
+ `;
922
+ const target = document.getElementById("target");
923
+ const parent = document.getElementById("parent");
924
+ for (const [k, v] of Object.entries(attrs))
925
+ target.setAttribute(k, v);
926
+ target.getBoundingClientRect = jest.fn(() => ({
927
+ x: rect.left,
928
+ y: rect.top,
929
+ left: rect.left,
930
+ top: rect.top,
931
+ right: rect.left + rect.width,
932
+ bottom: rect.top + rect.height,
933
+ width: rect.width,
934
+ height: rect.height,
935
+ toJSON: () => ({}),
936
+ }));
937
+ parent.getBoundingClientRect = jest.fn(() => ({
938
+ x: rect.left,
939
+ y: rect.top,
940
+ left: rect.left,
941
+ top: rect.top,
942
+ right: rect.left + rect.width,
943
+ bottom: rect.top + rect.height,
944
+ width: rect.width,
945
+ height: rect.height,
946
+ toJSON: () => ({}),
947
+ }));
948
+ // jsdom doesn't implement pointer-capture; stub so the directive's
949
+ // try/catch around it doesn't matter, but the test still asserts
950
+ // the contract.
951
+ target.setPointerCapture = jest.fn();
952
+ target.releasePointerCapture = jest.fn();
953
+ return [target, parent];
954
+ }
955
+ function dispatchPointer(el, type, clientX, clientY, pointerId = 1) {
956
+ const e = new MouseEvent(type, { clientX, clientY, button: 0, bubbles: true });
957
+ // PointerEvent isn't fully supported in jsdom but the directive
958
+ // only reads pointerId / isPrimary / button / clientX / clientY.
959
+ Object.defineProperty(e, "pointerId", { value: pointerId });
960
+ Object.defineProperty(e, "isPrimary", { value: true });
961
+ el.dispatchEvent(e);
962
+ }
963
+ beforeEach(() => {
964
+ document.body.innerHTML = "";
965
+ jest.spyOn(console, "warn").mockImplementation(() => { });
966
+ });
967
+ afterEach(() => {
968
+ jest.restoreAllMocks();
969
+ });
970
+ it("dispatches the action with 0..1 fraction coords on pointerup", () => {
971
+ const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 100, top: 50, width: 200, height: 200 });
972
+ const send = jest.fn();
973
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
974
+ // Drag from (120, 80) → (220, 150) inside the rect.
975
+ // x = (120 - 100) / 200 = 0.10
976
+ // y = (80 - 50) / 200 = 0.15
977
+ // w = (220 - 120) / 200 = 0.50
978
+ // h = (150 - 80) / 200 = 0.35
979
+ dispatchPointer(target, "pointerdown", 120, 80);
980
+ dispatchPointer(target, "pointermove", 220, 150);
981
+ dispatchPointer(target, "pointerup", 220, 150);
982
+ expect(send).toHaveBeenCalledTimes(1);
983
+ const msg = send.mock.calls[0][0];
984
+ expect(msg.action).toBe("selectImageArea");
985
+ expect(msg.data.x).toBeCloseTo(0.10, 5);
986
+ expect(msg.data.y).toBeCloseTo(0.15, 5);
987
+ expect(msg.data.w).toBeCloseTo(0.50, 5);
988
+ expect(msg.data.h).toBeCloseTo(0.35, 5);
989
+ });
990
+ it("filters drags smaller than the min-fraction threshold", () => {
991
+ const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 1000, height: 1000 });
992
+ const send = jest.fn();
993
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
994
+ // Drag is 10×10 px on a 1000×1000 rect → 1% fraction in both dims.
995
+ // 1% < MIN_AREA_FRACTION (2%) → must drop.
996
+ dispatchPointer(target, "pointerdown", 100, 100);
997
+ dispatchPointer(target, "pointermove", 110, 110);
998
+ dispatchPointer(target, "pointerup", 110, 110);
999
+ expect(send).not.toHaveBeenCalled();
1000
+ // Overlay should have been cleaned up after the failed drag.
1001
+ expect(document.querySelectorAll(".lvt-area-select-overlay").length).toBe(0);
1002
+ });
1003
+ it("paints an overlay during the drag and removes it on release", () => {
1004
+ const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1005
+ const target = parent.querySelector("img");
1006
+ const send = jest.fn();
1007
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1008
+ dispatchPointer(target, "pointerdown", 10, 10);
1009
+ expect(parent.querySelector(".lvt-area-select-overlay")).not.toBeNull();
1010
+ dispatchPointer(target, "pointermove", 60, 70);
1011
+ const overlay = parent.querySelector(".lvt-area-select-overlay");
1012
+ expect(overlay.style.width).toBe("50px");
1013
+ expect(overlay.style.height).toBe("60px");
1014
+ dispatchPointer(target, "pointerup", 60, 70);
1015
+ expect(parent.querySelector(".lvt-area-select-overlay")).toBeNull();
1016
+ });
1017
+ it("pointercancel removes the overlay and does NOT dispatch", () => {
1018
+ const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1019
+ const target = parent.querySelector("img");
1020
+ const send = jest.fn();
1021
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1022
+ dispatchPointer(target, "pointerdown", 10, 10);
1023
+ dispatchPointer(target, "pointermove", 60, 60);
1024
+ dispatchPointer(target, "pointercancel", 60, 60);
1025
+ expect(send).not.toHaveBeenCalled();
1026
+ expect(parent.querySelector(".lvt-area-select-overlay")).toBeNull();
1027
+ });
1028
+ it("is idempotent across renders for the same action", () => {
1029
+ const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1030
+ const send = jest.fn();
1031
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1032
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1033
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1034
+ dispatchPointer(target, "pointerdown", 10, 10);
1035
+ dispatchPointer(target, "pointermove", 100, 100);
1036
+ dispatchPointer(target, "pointerup", 100, 100);
1037
+ // Listeners must NOT have been duplicated by repeated calls.
1038
+ expect(send).toHaveBeenCalledTimes(1);
1039
+ });
1040
+ it("re-arms with new action when the attribute value changes", () => {
1041
+ const [target] = mountTarget("img", { "lvt-fx:area-select": "first" }, { left: 0, top: 0, width: 200, height: 200 });
1042
+ const send = jest.fn();
1043
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1044
+ target.setAttribute("lvt-fx:area-select", "second");
1045
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1046
+ dispatchPointer(target, "pointerdown", 10, 10);
1047
+ dispatchPointer(target, "pointermove", 100, 100);
1048
+ dispatchPointer(target, "pointerup", 100, 100);
1049
+ expect(send).toHaveBeenCalledTimes(1);
1050
+ expect(send.mock.calls[0][0].action).toBe("second");
1051
+ });
1052
+ it("warns and skips when the attribute value is empty", () => {
1053
+ const warn = console.warn;
1054
+ mountTarget("img", { "lvt-fx:area-select": "" }, { left: 0, top: 0, width: 200, height: 200 });
1055
+ const send = jest.fn();
1056
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1057
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("requires an action name"));
1058
+ });
1059
+ it("clamps coords to 0..1 when the drag escapes the element rect", () => {
1060
+ const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 100, top: 100, width: 200, height: 200 });
1061
+ const send = jest.fn();
1062
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1063
+ // Drag starts inside but ends far below-right of the rect.
1064
+ dispatchPointer(target, "pointerdown", 150, 150);
1065
+ dispatchPointer(target, "pointerup", 10000, 10000);
1066
+ expect(send).toHaveBeenCalledTimes(1);
1067
+ const data = send.mock.calls[0][0].data;
1068
+ expect(data.x).toBeGreaterThanOrEqual(0);
1069
+ expect(data.x).toBeLessThanOrEqual(1);
1070
+ expect(data.x + data.w).toBeLessThanOrEqual(1);
1071
+ expect(data.y + data.h).toBeLessThanOrEqual(1);
1072
+ });
1073
+ it("fast-path returns when no matching elements", () => {
1074
+ document.body.innerHTML = `<div><p>nothing here</p></div>`;
1075
+ const send = jest.fn();
1076
+ // Should not throw, should not dispatch.
1077
+ expect(() => (0, directives_1.handleAreaSelectDirectives)(document.body, send)).not.toThrow();
1078
+ expect(send).not.toHaveBeenCalled();
1079
+ });
1080
+ it("removes the overlay when the host is detached mid-drag", () => {
1081
+ const [target, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1082
+ const send = jest.fn();
1083
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1084
+ dispatchPointer(target, "pointerdown", 10, 10);
1085
+ expect(parent.querySelector(".lvt-area-select-overlay")).not.toBeNull();
1086
+ // Simulate a server diff replacing the host element.
1087
+ target.remove();
1088
+ // A late pointermove (jsdom dispatches to the detached element)
1089
+ // must clean up the overlay rather than leave it orphaned under
1090
+ // the parent.
1091
+ dispatchPointer(target, "pointermove", 60, 70);
1092
+ expect(parent.querySelector(".lvt-area-select-overlay")).toBeNull();
1093
+ });
1094
+ it("suppresses native <img> drag via dragstart preventDefault", () => {
1095
+ const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1096
+ (0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
1097
+ // Without the dragstart listener, Chromium would call default-
1098
+ // action (start a native image drag) and steal the gesture from
1099
+ // pointer events. The directive must call preventDefault on
1100
+ // dragstart so pointermove + pointerup keep arriving.
1101
+ const drag = new Event("dragstart", { bubbles: true, cancelable: true });
1102
+ target.dispatchEvent(drag);
1103
+ expect(drag.defaultPrevented).toBe(true);
1104
+ });
1105
+ it("positions overlay correctly when target is offset inside its parent", () => {
1106
+ // Parent at (0,0), target at (50, 25) — exercises the
1107
+ // border-box-to-padding-box offset math with a real gap.
1108
+ document.body.innerHTML = `
1109
+ <div id="parent" style="position:relative;">
1110
+ <img id="target" lvt-fx:area-select="selectImageArea">
1111
+ </div>
1112
+ `;
1113
+ const target = document.getElementById("target");
1114
+ const parent = document.getElementById("parent");
1115
+ target.getBoundingClientRect = jest.fn(() => ({
1116
+ x: 50, y: 25, left: 50, top: 25, right: 150, bottom: 125,
1117
+ width: 100, height: 100, toJSON: () => ({}),
1118
+ }));
1119
+ parent.getBoundingClientRect = jest.fn(() => ({
1120
+ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
1121
+ width: 200, height: 200, toJSON: () => ({}),
1122
+ }));
1123
+ target.setPointerCapture = jest.fn();
1124
+ target.releasePointerCapture = jest.fn();
1125
+ (0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
1126
+ // Drag from (80, 50) → (120, 90) — inside the target's 100×100 rect.
1127
+ // Relative to the parent (and after subtracting clientLeft/clientTop=0
1128
+ // for a borderless parent), the overlay should sit at left=80, top=50.
1129
+ dispatchPointer(target, "pointerdown", 80, 50);
1130
+ dispatchPointer(target, "pointermove", 120, 90);
1131
+ const overlay = parent.querySelector(".lvt-area-select-overlay");
1132
+ expect(overlay).not.toBeNull();
1133
+ expect(overlay.style.left).toBe("80px");
1134
+ expect(overlay.style.top).toBe("50px");
1135
+ expect(overlay.style.width).toBe("40px");
1136
+ expect(overlay.style.height).toBe("40px");
1137
+ });
1138
+ it("pointerleave for a different pointerId does NOT cancel our capture-fallback drag", () => {
1139
+ // When setPointerCapture fails the directive attaches a
1140
+ // pointerleave fallback so the drag can clean up. In a multi-
1141
+ // touch scenario, a SECONDARY pointer leaving the element fires
1142
+ // pointerleave too — must not be mistaken for our pointer
1143
+ // leaving.
1144
+ const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1145
+ const target = parent.querySelector("img");
1146
+ // Force capture failure so the pointerleave fallback is attached.
1147
+ target.setPointerCapture = jest.fn(() => {
1148
+ throw new DOMException("no capture", "InvalidStateError");
1149
+ });
1150
+ const send = jest.fn();
1151
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1152
+ // Primary drag with pointerId=1.
1153
+ dispatchPointer(target, "pointerdown", 10, 10, 1);
1154
+ dispatchPointer(target, "pointermove", 80, 80, 1);
1155
+ // Secondary pointer (id=42) leaves the host. Must NOT cancel
1156
+ // our id=1 drag.
1157
+ dispatchPointer(target, "pointerleave", 80, 80, 42);
1158
+ // Primary drag still alive — pointerup completes it normally.
1159
+ dispatchPointer(target, "pointerup", 100, 100, 1);
1160
+ expect(send).toHaveBeenCalledTimes(1);
1161
+ });
1162
+ it("lostpointercapture for a different pointerId does NOT cancel our drag", () => {
1163
+ // Another code path could call setPointerCapture on the same
1164
+ // element with a different pointerId and later release it,
1165
+ // firing lostpointercapture on the host. Our handler must not
1166
+ // mistake that for OUR drag being canceled.
1167
+ const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1168
+ const target = parent.querySelector("img");
1169
+ const send = jest.fn();
1170
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1171
+ // Our drag starts with pointerId=1.
1172
+ dispatchPointer(target, "pointerdown", 10, 10, 1);
1173
+ dispatchPointer(target, "pointermove", 80, 80, 1);
1174
+ // Unrelated lostpointercapture for pointerId=42 — must be ignored.
1175
+ dispatchPointer(target, "lostpointercapture", 80, 80, 42);
1176
+ // Our drag is still alive — pointerup dispatches normally.
1177
+ dispatchPointer(target, "pointerup", 100, 100, 1);
1178
+ expect(send).toHaveBeenCalledTimes(1);
1179
+ });
1180
+ it("releasePointerCapture firing lostpointercapture synchronously does not drop the dispatch", () => {
1181
+ // Chromium fires lostpointercapture SYNCHRONOUSLY during
1182
+ // releasePointerCapture. Without the early state-reset in
1183
+ // finalize, the nested lostpointercapture handler would see
1184
+ // pointerId still matching, run a nested finalize that clears
1185
+ // startRect, then the outer finalize would resume with rect=null
1186
+ // and silently drop the dispatched action.
1187
+ const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1188
+ const target = parent.querySelector("img");
1189
+ // Make releasePointerCapture fire lostpointercapture synchronously
1190
+ // — the real Chromium behaviour that wasn't covered by jest.fn().
1191
+ target.releasePointerCapture = jest.fn((pid) => {
1192
+ target.dispatchEvent(Object.assign(new MouseEvent("lostpointercapture", { bubbles: false }), {
1193
+ pointerId: pid,
1194
+ }));
1195
+ });
1196
+ const send = jest.fn();
1197
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1198
+ dispatchPointer(target, "pointerdown", 20, 20);
1199
+ dispatchPointer(target, "pointermove", 80, 80);
1200
+ dispatchPointer(target, "pointerup", 80, 80);
1201
+ // The action must still dispatch — the early state-reset prevents
1202
+ // the nested lostpointercapture from re-entering finalize and
1203
+ // clearing the rect mid-call.
1204
+ expect(send).toHaveBeenCalledTimes(1);
1205
+ });
1206
+ it("uses pointerdown-time rect for fractions even if host moves between mousemoves", () => {
1207
+ // If a server diff repositions the host mid-drag, the rect
1208
+ // captured AT POINTERUP would clamp startClientX (which was
1209
+ // captured against the OLD position) into the wrong place.
1210
+ // The dispatched fractions must reflect the original drag,
1211
+ // measured against the rect that existed at pointerdown.
1212
+ document.body.innerHTML = `
1213
+ <div id="parent" style="position:relative;"><img id="target" lvt-fx:area-select="selectImageArea"></div>
1214
+ `;
1215
+ const parent = document.getElementById("parent");
1216
+ const target = document.getElementById("target");
1217
+ // Rect call counter: first call (pointerdown) returns the OLD
1218
+ // rect, subsequent calls return a NEW rect 500px away.
1219
+ let rectCalls = 0;
1220
+ target.getBoundingClientRect = jest.fn(() => {
1221
+ rectCalls++;
1222
+ if (rectCalls === 1) {
1223
+ return {
1224
+ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
1225
+ width: 200, height: 200, toJSON: () => ({}),
1226
+ };
1227
+ }
1228
+ return {
1229
+ x: 500, y: 500, left: 500, top: 500, right: 700, bottom: 700,
1230
+ width: 200, height: 200, toJSON: () => ({}),
1231
+ };
1232
+ });
1233
+ parent.getBoundingClientRect = jest.fn(() => ({
1234
+ x: 0, y: 0, left: 0, top: 0, right: 1000, bottom: 1000,
1235
+ width: 1000, height: 1000, toJSON: () => ({}),
1236
+ }));
1237
+ target.setPointerCapture = jest.fn();
1238
+ target.releasePointerCapture = jest.fn();
1239
+ const send = jest.fn();
1240
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1241
+ // Drag from (40, 60) → (140, 160) inside the OLD rect at (0..200).
1242
+ // OLD-rect fractions: x=40/200=0.2, y=60/200=0.3, w=100/200=0.5,
1243
+ // h=100/200=0.5. With the new (500..700) rect at finalize time,
1244
+ // clamping (40,60) into that rect would silently shift the start.
1245
+ dispatchPointer(target, "pointerdown", 40, 60);
1246
+ dispatchPointer(target, "pointermove", 90, 110);
1247
+ dispatchPointer(target, "pointerup", 140, 160);
1248
+ expect(send).toHaveBeenCalledTimes(1);
1249
+ const data = send.mock.calls[0][0].data;
1250
+ expect(data.x).toBeCloseTo(0.2, 5);
1251
+ expect(data.y).toBeCloseTo(0.3, 5);
1252
+ expect(data.w).toBeCloseTo(0.5, 5);
1253
+ expect(data.h).toBeCloseTo(0.5, 5);
1254
+ });
1255
+ it("uses the pointerdown-time parent even if host moves between mousemoves", () => {
1256
+ // Two positioned parents at known offsets. The host starts under
1257
+ // the first; we begin a drag, then synthetically re-parent the
1258
+ // host into the second container mid-drag. Without the parent-
1259
+ // capture fix, updateOverlay would refetch el.parentElement and
1260
+ // compute against the SECOND parent while the overlay still
1261
+ // lives in the FIRST — visual mis-positioning for the rest of
1262
+ // the drag. With the fix, the overlay tracks the FIRST parent.
1263
+ document.body.innerHTML = `
1264
+ <div id="p1" style="position:relative;"></div>
1265
+ <div id="p2" style="position:relative;"></div>
1266
+ `;
1267
+ const p1 = document.getElementById("p1");
1268
+ const p2 = document.getElementById("p2");
1269
+ const target = document.createElement("img");
1270
+ target.setAttribute("lvt-fx:area-select", "selectImageArea");
1271
+ p1.appendChild(target);
1272
+ target.getBoundingClientRect = jest.fn(() => ({
1273
+ x: 10, y: 10, left: 10, top: 10, right: 110, bottom: 110,
1274
+ width: 100, height: 100, toJSON: () => ({}),
1275
+ }));
1276
+ p1.getBoundingClientRect = jest.fn(() => ({
1277
+ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
1278
+ width: 200, height: 200, toJSON: () => ({}),
1279
+ }));
1280
+ p2.getBoundingClientRect = jest.fn(() => ({
1281
+ x: 500, y: 500, left: 500, top: 500, right: 700, bottom: 700,
1282
+ width: 200, height: 200, toJSON: () => ({}),
1283
+ }));
1284
+ target.setPointerCapture = jest.fn();
1285
+ target.releasePointerCapture = jest.fn();
1286
+ (0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
1287
+ dispatchPointer(target, "pointerdown", 30, 30);
1288
+ // Server diff moves the host to p2.
1289
+ p2.appendChild(target);
1290
+ dispatchPointer(target, "pointermove", 80, 60);
1291
+ // Overlay stays in p1 (where pointerdown attached it). Position is
1292
+ // computed against p1's rect (cached at pointerdown), not p2's.
1293
+ const overlayInP1 = p1.querySelector(".lvt-area-select-overlay");
1294
+ const overlayInP2 = p2.querySelector(".lvt-area-select-overlay");
1295
+ expect(overlayInP1).not.toBeNull();
1296
+ expect(overlayInP2).toBeNull();
1297
+ // pointerdown at (30,30), move to (80,60) → 50×30 against p1
1298
+ // at (0,0). If updateOverlay had re-fetched el.parentElement
1299
+ // and used p2 (at 500,500), left would be -470 instead of 30.
1300
+ expect(overlayInP1.style.left).toBe("30px");
1301
+ });
1302
+ it("positions overlay correctly when parent is scrolled", () => {
1303
+ // For a scrolled positioned parent: an element at viewport_x =
1304
+ // parentRect.left actually has CSS_left = parent.scrollLeft (the
1305
+ // browser is scrolled, so what's at viewport-x-0 is parent-x-100
1306
+ // for scrollLeft=100). Without adding scrollLeft/scrollTop back
1307
+ // into the CSS coords, the overlay paints displaced by the scroll
1308
+ // amount.
1309
+ document.body.innerHTML = `
1310
+ <div id="parent" style="position:relative;"><img id="target" lvt-fx:area-select="selectImageArea"></div>
1311
+ `;
1312
+ const parent = document.getElementById("parent");
1313
+ const target = document.getElementById("target");
1314
+ target.getBoundingClientRect = jest.fn(() => ({
1315
+ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
1316
+ width: 200, height: 200, toJSON: () => ({}),
1317
+ }));
1318
+ parent.getBoundingClientRect = jest.fn(() => ({
1319
+ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
1320
+ width: 200, height: 200, toJSON: () => ({}),
1321
+ }));
1322
+ // jsdom: scrollLeft/Top are mutable properties; just assign.
1323
+ Object.defineProperty(parent, "scrollLeft", { value: 100, configurable: true });
1324
+ Object.defineProperty(parent, "scrollTop", { value: 50, configurable: true });
1325
+ target.setPointerCapture = jest.fn();
1326
+ target.releasePointerCapture = jest.fn();
1327
+ (0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
1328
+ // Drag from viewport (30, 40) to (90, 100). With the scroll
1329
+ // correction the overlay's CSS left should be
1330
+ // 30 - 0 - 0 + 100 = 130 and CSS top should be
1331
+ // 40 - 0 - 0 + 50 = 90. Without it, left=30 / top=40 (the bug).
1332
+ dispatchPointer(target, "pointerdown", 30, 40);
1333
+ dispatchPointer(target, "pointermove", 90, 100);
1334
+ const overlay = parent.querySelector(".lvt-area-select-overlay");
1335
+ expect(overlay).not.toBeNull();
1336
+ expect(overlay.style.left).toBe("130px");
1337
+ expect(overlay.style.top).toBe("90px");
1338
+ expect(overlay.style.width).toBe("60px");
1339
+ expect(overlay.style.height).toBe("60px");
1340
+ });
1341
+ it("warns when the parent's computed position is `static`", () => {
1342
+ // Forgetting position:relative on the parent silently mis-paints
1343
+ // the overlay against the nearest positioned ancestor. A
1344
+ // dev-time warn gives the author a chance to spot the mistake.
1345
+ const warn = console.warn;
1346
+ document.body.innerHTML = `
1347
+ <div id="static-parent">
1348
+ <img id="target" lvt-fx:area-select="selectImageArea">
1349
+ </div>
1350
+ `;
1351
+ const target = document.getElementById("target");
1352
+ target.getBoundingClientRect = jest.fn(() => ({
1353
+ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
1354
+ width: 200, height: 200, toJSON: () => ({}),
1355
+ }));
1356
+ target.setPointerCapture = jest.fn();
1357
+ target.releasePointerCapture = jest.fn();
1358
+ (0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
1359
+ dispatchPointer(target, "pointerdown", 10, 10);
1360
+ const warnings = warn.mock.calls.filter((args) => typeof args[0] === "string" && args[0].includes("parentElement has no positioning context"));
1361
+ expect(warnings.length).toBe(1);
1362
+ });
1363
+ it("dedupes the static-parent warn across repeated drags", () => {
1364
+ // Without the WeakSet dedupe, a user repeatedly dragging on the
1365
+ // same mis-configured element would spam console.warn (and
1366
+ // re-run getComputedStyle, a style-recalc trigger) once per
1367
+ // pointerdown.
1368
+ const warn = console.warn;
1369
+ document.body.innerHTML = `
1370
+ <div id="static-parent">
1371
+ <img id="target" lvt-fx:area-select="selectImageArea">
1372
+ </div>
1373
+ `;
1374
+ const target = document.getElementById("target");
1375
+ target.getBoundingClientRect = jest.fn(() => ({
1376
+ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
1377
+ width: 200, height: 200, toJSON: () => ({}),
1378
+ }));
1379
+ target.setPointerCapture = jest.fn();
1380
+ target.releasePointerCapture = jest.fn();
1381
+ (0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
1382
+ // Three drags on the same mis-configured parent — only the FIRST
1383
+ // should warn.
1384
+ for (let i = 0; i < 3; i++) {
1385
+ dispatchPointer(target, "pointerdown", 10, 10);
1386
+ dispatchPointer(target, "pointerup", 60, 60);
1387
+ }
1388
+ const warnings = warn.mock.calls.filter((args) => typeof args[0] === "string" && args[0].includes("parentElement has no positioning context"));
1389
+ expect(warnings.length).toBe(1);
1390
+ });
1391
+ it("pointercancel cancels the drag without dispatching", () => {
1392
+ // pointercancel fires on system gestures (OS-level swipe, app
1393
+ // switch). Like lostpointercapture / pointerleave, it must remove
1394
+ // the overlay and NOT dispatch — same contract from the user's
1395
+ // perspective.
1396
+ const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1397
+ const target = parent.querySelector("img");
1398
+ const send = jest.fn();
1399
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1400
+ dispatchPointer(target, "pointerdown", 10, 10);
1401
+ dispatchPointer(target, "pointermove", 80, 80);
1402
+ dispatchPointer(target, "pointercancel", 80, 80);
1403
+ expect(send).not.toHaveBeenCalled();
1404
+ expect(parent.querySelector(".lvt-area-select-overlay")).toBeNull();
1405
+ });
1406
+ it("rejects zero-area rectangles even if the threshold check would pass", () => {
1407
+ // The MIN_AREA_FRACTION check uses && so a wide-but-thin drag is
1408
+ // a legit selection — but a literal 60% × 0% drag has no area,
1409
+ // can't render sensibly, and would divide by zero downstream.
1410
+ // Drop it independently of the threshold.
1411
+ const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1412
+ const send = jest.fn();
1413
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1414
+ // Drag from (40,100) to (160,100) — w=60% of 200, h=0%.
1415
+ dispatchPointer(target, "pointerdown", 40, 100);
1416
+ dispatchPointer(target, "pointermove", 160, 100);
1417
+ dispatchPointer(target, "pointerup", 160, 100);
1418
+ expect(send).not.toHaveBeenCalled();
1419
+ });
1420
+ it("teardownAreaSelectForRoot cancels armed elements under root", () => {
1421
+ // For the disconnect / destroy lifecycle: if a client tears down
1422
+ // without a subsequent handleAreaSelectDirectives call, the
1423
+ // module-level singleton would otherwise leak listeners.
1424
+ document.body.innerHTML = `
1425
+ <div id="root">
1426
+ <div id="parent" style="position:relative;">
1427
+ <img id="target" lvt-fx:area-select="selectImageArea">
1428
+ </div>
1429
+ </div>
1430
+ <div id="outside-parent" style="position:relative;">
1431
+ <img id="outside-target" lvt-fx:area-select="otherAction">
1432
+ </div>
1433
+ `;
1434
+ const root = document.getElementById("root");
1435
+ const target = document.getElementById("target");
1436
+ const outside = document.getElementById("outside-target");
1437
+ for (const el of [target, outside]) {
1438
+ el.getBoundingClientRect = jest.fn(() => ({
1439
+ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
1440
+ width: 200, height: 200, toJSON: () => ({}),
1441
+ }));
1442
+ el.parentElement.getBoundingClientRect = jest.fn(() => ({
1443
+ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200,
1444
+ width: 200, height: 200, toJSON: () => ({}),
1445
+ }));
1446
+ el.setPointerCapture = jest.fn();
1447
+ el.releasePointerCapture = jest.fn();
1448
+ }
1449
+ const send = jest.fn();
1450
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1451
+ (0, directives_1.teardownAreaSelectForRoot)(root);
1452
+ // The target inside root must NOT dispatch after teardown.
1453
+ dispatchPointer(target, "pointerdown", 10, 10);
1454
+ dispatchPointer(target, "pointermove", 100, 100);
1455
+ dispatchPointer(target, "pointerup", 100, 100);
1456
+ expect(send).not.toHaveBeenCalled();
1457
+ // The target outside root must still work.
1458
+ dispatchPointer(outside, "pointerdown", 10, 10);
1459
+ dispatchPointer(outside, "pointermove", 100, 100);
1460
+ dispatchPointer(outside, "pointerup", 100, 100);
1461
+ expect(send).toHaveBeenCalledTimes(1);
1462
+ expect(send.mock.calls[0][0].action).toBe("otherAction");
1463
+ (0, directives_1.teardownAreaSelectForRoot)(document.body); // clean up for next test
1464
+ });
1465
+ it("does not preventDefault on pointerdown — clicks still bubble", () => {
1466
+ // The contract promises a small-rect drag (treated as a click)
1467
+ // still reaches the host's click handlers. Calling
1468
+ // preventDefault on pointerdown would suppress the compatibility
1469
+ // mouse events that fire click — so the directive must NOT do
1470
+ // that.
1471
+ const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1472
+ (0, directives_1.handleAreaSelectDirectives)(document.body, jest.fn());
1473
+ const down = new MouseEvent("pointerdown", {
1474
+ clientX: 50, clientY: 50, button: 0, bubbles: true, cancelable: true,
1475
+ });
1476
+ Object.defineProperty(down, "pointerId", { value: 1 });
1477
+ Object.defineProperty(down, "isPrimary", { value: true });
1478
+ target.dispatchEvent(down);
1479
+ expect(down.defaultPrevented).toBe(false);
1480
+ });
1481
+ it("stale pointerleave listener does not survive into the next gesture", () => {
1482
+ // The bug Claude flagged: when setPointerCapture fails on drag N,
1483
+ // the directive registers a pointerleave fallback. If drag N
1484
+ // gets stuck (pointer never leaves so the fallback never fires
1485
+ // and no pointerup arrives), and the user starts drag N+1, the
1486
+ // re-entrancy guard finalizes drag N but the STALE pointerleave
1487
+ // listener from N would still be attached. If capture SUCCEEDS
1488
+ // on drag N+1 (no new pointerleave registered), the stale one
1489
+ // from N would still fire if the pointer ever leaves — and
1490
+ // incorrectly cancel drag N+1. finalize() must remove the
1491
+ // pointerleave listener so it can't outlive its own gesture.
1492
+ const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1493
+ // Capture FAILS only on the first call (drag N), succeeds after.
1494
+ let captureCalls = 0;
1495
+ target.setPointerCapture = jest.fn(() => {
1496
+ captureCalls++;
1497
+ if (captureCalls === 1) {
1498
+ throw new DOMException("no capture", "InvalidStateError");
1499
+ }
1500
+ });
1501
+ const send = jest.fn();
1502
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1503
+ // Drag N: pointerdown attaches the pointerleave fallback, but
1504
+ // user starts drag and never releases (simulates a "stuck"
1505
+ // drag). Do NOT pointerup.
1506
+ dispatchPointer(target, "pointerdown", 10, 10);
1507
+ dispatchPointer(target, "pointermove", 80, 80);
1508
+ // Drag N+1 starts. Re-entrancy guard finalizes drag N. WITHOUT
1509
+ // the fix, drag N's pointerleave listener stays attached.
1510
+ // Capture succeeds this time so no NEW pointerleave is attached.
1511
+ dispatchPointer(target, "pointerdown", 20, 20);
1512
+ dispatchPointer(target, "pointermove", 100, 100);
1513
+ // Fire pointerleave. With the fix, no pointerleave handler is
1514
+ // attached → no-op, drag N+1 continues. WITHOUT the fix, the
1515
+ // stale listener from N would call finalize and cancel.
1516
+ target.dispatchEvent(new MouseEvent("pointerleave", { bubbles: false }));
1517
+ dispatchPointer(target, "pointerup", 100, 100);
1518
+ // Drag N+1 should have dispatched normally — the stale listener
1519
+ // must NOT have cancelled it.
1520
+ expect(send).toHaveBeenCalledTimes(1);
1521
+ });
1522
+ it("idempotent re-arm picks up the latest send callback (no stale closure)", () => {
1523
+ const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1524
+ const firstSend = jest.fn();
1525
+ const secondSend = jest.fn();
1526
+ (0, directives_1.handleAreaSelectDirectives)(document.body, firstSend);
1527
+ // A subsequent render passes a different send (e.g. after a WS
1528
+ // reconnect rebuilt the transport). The idempotent path keeps
1529
+ // the listeners but MUST swap the captured send so the next
1530
+ // drag dispatches through the latest callback.
1531
+ (0, directives_1.handleAreaSelectDirectives)(document.body, secondSend);
1532
+ dispatchPointer(target, "pointerdown", 10, 10);
1533
+ dispatchPointer(target, "pointermove", 100, 100);
1534
+ dispatchPointer(target, "pointerup", 100, 100);
1535
+ expect(firstSend).not.toHaveBeenCalled();
1536
+ expect(secondSend).toHaveBeenCalledTimes(1);
1537
+ });
1538
+ it("lostpointercapture cancels the drag without dispatching", () => {
1539
+ const [, parent] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1540
+ const target = parent.querySelector("img");
1541
+ const send = jest.fn();
1542
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1543
+ dispatchPointer(target, "pointerdown", 10, 10);
1544
+ dispatchPointer(target, "pointermove", 80, 80);
1545
+ // Platform yanks capture (OS gesture, another setPointerCapture
1546
+ // call elsewhere). lostpointercapture should cancel like
1547
+ // pointercancel — overlay removed, no action dispatched.
1548
+ dispatchPointer(target, "lostpointercapture", 80, 80);
1549
+ expect(send).not.toHaveBeenCalled();
1550
+ expect(parent.querySelector(".lvt-area-select-overlay")).toBeNull();
1551
+ });
1552
+ it("cleans up armed elements whose attribute was removed by a server diff", () => {
1553
+ const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1554
+ const send = jest.fn();
1555
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1556
+ // Server diff removes the attribute. The element stays in the DOM
1557
+ // (host alive, but no longer wants area-select). The next
1558
+ // handleAreaSelectDirectives pass MUST cancel the listeners — a
1559
+ // subsequent drag must not dispatch the old action.
1560
+ target.removeAttribute("lvt-fx:area-select");
1561
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1562
+ dispatchPointer(target, "pointerdown", 10, 10);
1563
+ dispatchPointer(target, "pointermove", 100, 100);
1564
+ dispatchPointer(target, "pointerup", 100, 100);
1565
+ expect(send).not.toHaveBeenCalled();
1566
+ });
1567
+ it("recovers from a stuck drag on the next pointerdown (re-entrancy guard)", () => {
1568
+ const [target] = mountTarget("img", { "lvt-fx:area-select": "selectImageArea" }, { left: 0, top: 0, width: 200, height: 200 });
1569
+ const send = jest.fn();
1570
+ (0, directives_1.handleAreaSelectDirectives)(document.body, send);
1571
+ // First drag: pointerdown but no pointerup (simulates a captured
1572
+ // pointer that never released — what happens when capture silently
1573
+ // fails and pointer leaves the element).
1574
+ dispatchPointer(target, "pointerdown", 10, 10);
1575
+ dispatchPointer(target, "pointermove", 60, 60);
1576
+ // No pointerup. The drag is "stuck".
1577
+ // Second drag starts. Without the re-entrancy guard, the first
1578
+ // overlay would be orphaned. With the guard, the directive cancels
1579
+ // the stuck drag before starting the new one, and the new drag
1580
+ // completes normally.
1581
+ dispatchPointer(target, "pointerdown", 100, 100);
1582
+ dispatchPointer(target, "pointermove", 150, 150);
1583
+ dispatchPointer(target, "pointerup", 150, 150);
1584
+ expect(send).toHaveBeenCalledTimes(1);
1585
+ // The dispatched coords must come from the SECOND drag, not the
1586
+ // first. (Start=100, End=150 → x=0.5, w=0.25 on the 200-wide rect.)
1587
+ const data = send.mock.calls[0][0].data;
1588
+ expect(data.x).toBeCloseTo(0.50, 5);
1589
+ expect(data.w).toBeCloseTo(0.25, 5);
1590
+ // Exactly one overlay at most over the whole sequence (the second
1591
+ // drag's) — never two.
1592
+ expect(document.querySelectorAll(".lvt-area-select-overlay").length).toBe(0);
1593
+ });
1594
+ });
651
1595
  //# sourceMappingURL=directives.test.js.map