Skip to content

Commit 96047cd

Browse files
committed
[Bug Fix] Popover: set data-state/data-side, clear closeTimeout on disconnect, close on Escape (#494)
1 parent 603b011 commit 96047cd

5 files changed

Lines changed: 59 additions & 5 deletions

File tree

‎docs/app/javascript/controllers/ruby_ui/popover_controller.js‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ export default class extends Controller {
2323

2424
disconnect() {
2525
this.removeEventListeners();
26+
clearTimeout(this.closeTimeout);
27+
document.removeEventListener("keydown", this.handleKeydown);
2628
if (this.cleanup) {
2729
this.cleanup();
30+
this.cleanup = null;
2831
}
2932
}
3033

@@ -75,15 +78,29 @@ export default class extends Controller {
7578
}
7679
};
7780

81+
handleKeydown = (event) => {
82+
if (event.key !== "Escape") return;
83+
if (!this.openValue) return;
84+
85+
clearTimeout(this.closeTimeout);
86+
this.openValue = false;
87+
this.hidePopover();
88+
};
89+
7890
showPopover() {
7991
this.contentTarget.classList.remove("hidden");
92+
this.contentTarget.dataset.state = "open";
93+
document.addEventListener("keydown", this.handleKeydown);
8094
this.updatePosition();
8195
}
8296

8397
hidePopover() {
8498
this.contentTarget.classList.add("hidden");
99+
this.contentTarget.dataset.state = "closed";
100+
document.removeEventListener("keydown", this.handleKeydown);
85101
if (this.cleanup) {
86102
this.cleanup();
103+
this.cleanup = null;
87104
}
88105
}
89106

@@ -96,11 +113,14 @@ export default class extends Controller {
96113
computePosition(this.triggerTarget, this.contentTarget, {
97114
placement: this.optionsValue.placement || "bottom",
98115
middleware: [flip(), shift(), offset(8)],
99-
}).then(({ x, y }) => {
116+
}).then(({ x, y, placement }) => {
100117
Object.assign(this.contentTarget.style, {
101118
left: `${x}px`,
102119
top: `${y}px`,
103120
});
121+
// flip() can resolve to the opposite side of the requested placement,
122+
// so the directional slide-in classes must follow the resolved value.
123+
this.contentTarget.dataset.side = placement.split("-")[0];
104124
});
105125
});
106126
}

‎gem/lib/ruby_ui/popover/popover_content.rb‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ def view_template(&)
1111
def default_attrs
1212
{
1313
data: {
14-
ruby_ui__popover_target: "content"
14+
ruby_ui__popover_target: "content",
15+
state: :closed
1516
},
1617
class: [
1718
"hidden z-50 rounded-md border bg-background p-1 text-foreground shadow-md outline-none",

‎gem/lib/ruby_ui/popover/popover_controller.js‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ export default class extends Controller {
2323

2424
disconnect() {
2525
this.removeEventListeners();
26+
clearTimeout(this.closeTimeout);
27+
document.removeEventListener("keydown", this.handleKeydown);
2628
if (this.cleanup) {
2729
this.cleanup();
30+
this.cleanup = null;
2831
}
2932
}
3033

@@ -75,15 +78,29 @@ export default class extends Controller {
7578
}
7679
};
7780

81+
handleKeydown = (event) => {
82+
if (event.key !== "Escape") return;
83+
if (!this.openValue) return;
84+
85+
clearTimeout(this.closeTimeout);
86+
this.openValue = false;
87+
this.hidePopover();
88+
};
89+
7890
showPopover() {
7991
this.contentTarget.classList.remove("hidden");
92+
this.contentTarget.dataset.state = "open";
93+
document.addEventListener("keydown", this.handleKeydown);
8094
this.updatePosition();
8195
}
8296

8397
hidePopover() {
8498
this.contentTarget.classList.add("hidden");
99+
this.contentTarget.dataset.state = "closed";
100+
document.removeEventListener("keydown", this.handleKeydown);
85101
if (this.cleanup) {
86102
this.cleanup();
103+
this.cleanup = null;
87104
}
88105
}
89106

@@ -96,11 +113,14 @@ export default class extends Controller {
96113
computePosition(this.triggerTarget, this.contentTarget, {
97114
placement: this.optionsValue.placement || "bottom",
98115
middleware: [flip(), shift(), offset(8)],
99-
}).then(({ x, y }) => {
116+
}).then(({ x, y, placement }) => {
100117
Object.assign(this.contentTarget.style, {
101118
left: `${x}px`,
102119
top: `${y}px`,
103120
});
121+
// flip() can resolve to the opposite side of the requested placement,
122+
// so the directional slide-in classes must follow the resolved value.
123+
this.contentTarget.dataset.side = placement.split("-")[0];
104124
});
105125
});
106126
}

‎gem/test/ruby_ui/popover_test.rb‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,17 @@ def test_render_with_all_items
2525

2626
assert_match(/Profile/, output)
2727
end
28+
29+
# The animation classes on the content are keyed on data-state, so the
30+
# attribute has to be present before the Stimulus controller ever runs.
31+
def test_content_renders_closed_state_by_default
32+
output = phlex do
33+
RubyUI.PopoverContent { "popover body" }
34+
end
35+
36+
assert_match(/data-state="closed"/, output)
37+
assert_match(/hidden/, output)
38+
assert_match(/absolute/, output)
39+
assert_match(/popover body/, output)
40+
end
2841
end

‎mcp/data/registry.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2053,11 +2053,11 @@
20532053
},
20542054
{
20552055
"path": "popover_content.rb",
2056-
"content": "# frozen_string_literal: true\n\nmodule RubyUI\n class PopoverContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__popover_target: \"content\"\n },\n class: [\n \"hidden z-50 rounded-md border bg-background p-1 text-foreground shadow-md outline-none\",\n \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0\",\n \"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\",\n \"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2\",\n \"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n \"absolute\"\n ]\n }\n end\n end\nend\n"
2056+
"content": "# frozen_string_literal: true\n\nmodule RubyUI\n class PopoverContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__popover_target: \"content\",\n state: :closed\n },\n class: [\n \"hidden z-50 rounded-md border bg-background p-1 text-foreground shadow-md outline-none\",\n \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0\",\n \"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\",\n \"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2\",\n \"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n \"absolute\"\n ]\n }\n end\n end\nend\n"
20572057
},
20582058
{
20592059
"path": "popover_controller.js",
2060-
"content": "import { Controller } from \"@hotwired/stimulus\";\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n} from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\"];\n static values = {\n open: { type: Boolean, default: false },\n options: { type: Object, default: {} },\n trigger: { type: String, default: \"hover\" },\n };\n\n connect() {\n this.closeTimeout = null;\n this.cleanup = null;\n this.addEventListeners();\n }\n\n disconnect() {\n this.removeEventListeners();\n if (this.cleanup) {\n this.cleanup();\n }\n }\n\n addEventListeners() {\n if (this.triggerValue === \"hover\") {\n this.triggerTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n this.contentTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n } else if (this.triggerValue === \"click\") {\n this.triggerTarget.addEventListener(\"click\", this.handleClick);\n document.addEventListener(\"click\", this.handleOutsideClick);\n }\n }\n\n removeEventListeners() {\n this.triggerTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n this.contentTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n this.triggerTarget.removeEventListener(\"click\", this.handleClick);\n document.removeEventListener(\"click\", this.handleOutsideClick);\n }\n\n handleMouseEnter = () => {\n clearTimeout(this.closeTimeout);\n this.openValue = true;\n this.showPopover();\n };\n\n handleMouseLeave = () => {\n this.closeTimeout = setTimeout(() => {\n this.openValue = false;\n this.hidePopover();\n }, 100);\n };\n\n handleClick = (event) => {\n event.stopPropagation();\n this.openValue = !this.openValue;\n this.openValue ? this.showPopover() : this.hidePopover();\n };\n\n handleOutsideClick = (event) => {\n if (!this.element.contains(event.target) && this.openValue) {\n this.openValue = false;\n this.hidePopover();\n }\n };\n\n showPopover() {\n this.contentTarget.classList.remove(\"hidden\");\n this.updatePosition();\n }\n\n hidePopover() {\n this.contentTarget.classList.add(\"hidden\");\n if (this.cleanup) {\n this.cleanup();\n }\n }\n\n updatePosition() {\n if (this.cleanup) {\n this.cleanup();\n }\n\n this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {\n computePosition(this.triggerTarget, this.contentTarget, {\n placement: this.optionsValue.placement || \"bottom\",\n middleware: [flip(), shift(), offset(8)],\n }).then(({ x, y }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n}\n"
2060+
"content": "import { Controller } from \"@hotwired/stimulus\";\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n} from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\"];\n static values = {\n open: { type: Boolean, default: false },\n options: { type: Object, default: {} },\n trigger: { type: String, default: \"hover\" },\n };\n\n connect() {\n this.closeTimeout = null;\n this.cleanup = null;\n this.addEventListeners();\n }\n\n disconnect() {\n this.removeEventListeners();\n clearTimeout(this.closeTimeout);\n document.removeEventListener(\"keydown\", this.handleKeydown);\n if (this.cleanup) {\n this.cleanup();\n this.cleanup = null;\n }\n }\n\n addEventListeners() {\n if (this.triggerValue === \"hover\") {\n this.triggerTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n this.contentTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n } else if (this.triggerValue === \"click\") {\n this.triggerTarget.addEventListener(\"click\", this.handleClick);\n document.addEventListener(\"click\", this.handleOutsideClick);\n }\n }\n\n removeEventListeners() {\n this.triggerTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n this.contentTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n this.triggerTarget.removeEventListener(\"click\", this.handleClick);\n document.removeEventListener(\"click\", this.handleOutsideClick);\n }\n\n handleMouseEnter = () => {\n clearTimeout(this.closeTimeout);\n this.openValue = true;\n this.showPopover();\n };\n\n handleMouseLeave = () => {\n this.closeTimeout = setTimeout(() => {\n this.openValue = false;\n this.hidePopover();\n }, 100);\n };\n\n handleClick = (event) => {\n event.stopPropagation();\n this.openValue = !this.openValue;\n this.openValue ? this.showPopover() : this.hidePopover();\n };\n\n handleOutsideClick = (event) => {\n if (!this.element.contains(event.target) && this.openValue) {\n this.openValue = false;\n this.hidePopover();\n }\n };\n\n handleKeydown = (event) => {\n if (event.key !== \"Escape\") return;\n if (!this.openValue) return;\n\n clearTimeout(this.closeTimeout);\n this.openValue = false;\n this.hidePopover();\n };\n\n showPopover() {\n this.contentTarget.classList.remove(\"hidden\");\n this.contentTarget.dataset.state = \"open\";\n document.addEventListener(\"keydown\", this.handleKeydown);\n this.updatePosition();\n }\n\n hidePopover() {\n this.contentTarget.classList.add(\"hidden\");\n this.contentTarget.dataset.state = \"closed\";\n document.removeEventListener(\"keydown\", this.handleKeydown);\n if (this.cleanup) {\n this.cleanup();\n this.cleanup = null;\n }\n }\n\n updatePosition() {\n if (this.cleanup) {\n this.cleanup();\n }\n\n this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {\n computePosition(this.triggerTarget, this.contentTarget, {\n placement: this.optionsValue.placement || \"bottom\",\n middleware: [flip(), shift(), offset(8)],\n }).then(({ x, y, placement }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n // flip() can resolve to the opposite side of the requested placement,\n // so the directional slide-in classes must follow the resolved value.\n this.contentTarget.dataset.side = placement.split(\"-\")[0];\n });\n });\n }\n}\n"
20612061
},
20622062
{
20632063
"path": "popover_trigger.rb",

0 commit comments

Comments
 (0)