Skip to content

Commit 71b8532

Browse files
authored
[render preview] [Feature] Remove Popper.js — port hover_card & context_menu to Floating UI (#158) (#438)
* [Feature] Remove Popper.js — port hover_card & context_menu to Floating UI (#158) tippy.js (which bundles @popperjs/core) was the last dependency pulling Popper into RubyUI and breaking importmap installs. The other nine components already use @floating-ui/dom; this ports the final two. Gem: - Rewrite hover_card & context_menu Stimulus controllers on @floating-ui/dom (computePosition + autoUpdate, flip/shift/offset), dropping tippy. - Content components now render a real hidden, absolutely-positioned div instead of a cloned <template> (Floating UI positions a live element). - dependencies.yml: hover_card/context_menu -> @floating-ui/dom. - javascript_utils.rb: drop pin_tippy_js (tippy + @popperjs/core CDN pins). - package.json: drop tippy.js. Docs: - Sync both controllers, drop tippy.js from package.json + lockfile, remove the tippy/popper importmap install snippet. Tests: - Assert content renders a hidden positioned div, not a <template>. * [Bug Fix] Address review: hover_card/context_menu interaction fixes Fixes from PR review (cubic): - hover_card: Escape now closes the card. Was gated behind an early return for the no-menu-items case, making it unreachable for the common hover-card usage. - hover_card: add focusin/focusout listeners to the content element so moving keyboard focus into the card no longer auto-closes it. - context_menu: dismiss on outside right-click (contextmenu), not just left-click, so the native menu can take over instead of leaving a stale menu open. - both: controllers now toggle data-state open/closed in sync with the hidden class, keeping semantic state and state-based animation classes accurate instead of permanently "open". * [Bug Fix] Rebuild MCP registry.json for Floating UI migration CI 'MCP registry up to date' check rebuilds registry.json from ../gem and fails on diff. Regenerated so hover_card/context_menu reflect @floating-ui/dom (not tippy.js) and the updated component sources. * [Bug Fix] Content initial data-state closed (sync with hidden) cubic flagged HoverCardContent rendering data-state=open while hidden, which left the enter animation classes active before first show and made semantic state inconsistent. Initial state is now 'closed' (matches the hidden class); the controllers flip it to 'open' on show, so the data-[state=open] enter animation actually plays on reveal. Applied to both hover_card and context_menu content; registry.json regenerated.
1 parent fe69c94 commit 71b8532

15 files changed

Lines changed: 448 additions & 360 deletions

File tree

docs/app/components/component_setup/manual_steps.rb

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -174,20 +174,14 @@ def component_dependencies_steps(steps)
174174
end
175175

176176
# Temporary solution while we don't remove
177-
# motion adn tippy.js dependencies
177+
# the motion dependency
178178
def pin_importmap_instructions(js_package)
179179
case js_package
180180
when "motion"
181181
<<~CODE
182182
// Add to your config/importmap.rb
183183
pin "motion", to: "https://cdn.jsdelivr.net/npm/motion@10.18.0/+esm"
184184
CODE
185-
when "tippy.js"
186-
<<~CODE
187-
// Add to your config/importmap.rb
188-
pin "tippy.js", to: "https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/+esm"
189-
pin "@popperjs/core", to: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/+esm"\n
190-
CODE
191185
else
192186
"bin/importmap pin #{js_package}"
193187
end
Lines changed: 87 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,125 @@
11
import { Controller } from "@hotwired/stimulus";
2-
import tippy from "tippy.js";
2+
import {
3+
computePosition,
4+
flip,
5+
shift,
6+
offset,
7+
autoUpdate,
8+
} from "@floating-ui/dom";
39

410
export default class extends Controller {
511
static targets = ["trigger", "content", "menuItem"];
612
static values = {
7-
options: {
8-
type: Object,
9-
default: {},
10-
},
11-
// make content width of the trigger element (true/false)
12-
matchWidth: {
13-
type: Boolean,
14-
default: false,
15-
}
16-
}
13+
open: { type: Boolean, default: false },
14+
options: { type: Object, default: {} },
15+
// make content width match the trigger element (true/false)
16+
matchWidth: { type: Boolean, default: false },
17+
};
1718

1819
connect() {
19-
this.boundHandleKeydown = this.handleKeydown.bind(this); // Bind the function so we can remove it later
20-
this.initializeTippy();
20+
this.cleanup = null;
2121
this.selectedIndex = -1;
22+
this.boundHandleKeydown = this.handleKeydown.bind(this);
2223
}
2324

2425
disconnect() {
25-
this.destroyTippy();
26+
this.hide();
2627
}
2728

28-
initializeTippy() {
29-
const defaultOptions = {
30-
content: this.contentTarget.innerHTML,
31-
allowHTML: true,
32-
interactive: true,
33-
onShow: (instance) => {
34-
this.matchWidthValue && this.setContentWidth(instance); // ensure content width matches trigger width
35-
this.addEventListeners();
36-
},
37-
onHide: () => {
38-
this.removeEventListeners();
39-
this.deselectAll();
40-
},
41-
popperOptions: {
42-
modifiers: [
43-
{
44-
name: "offset",
45-
options: {
46-
offset: [0, 4]
47-
},
48-
},
49-
],
50-
}
51-
};
52-
53-
const mergedOptions = { ...this.optionsValue, ...defaultOptions };
54-
this.tippy = tippy(this.triggerTarget, mergedOptions);
29+
handleContextMenu(event) {
30+
event.preventDefault();
31+
this.open();
5532
}
5633

57-
destroyTippy() {
58-
if (this.tippy) {
59-
this.tippy.destroy();
34+
open() {
35+
this.openValue = true;
36+
this.contentTarget.classList.remove("hidden");
37+
this.contentTarget.dataset.state = "open";
38+
if (this.matchWidthValue) {
39+
this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`;
6040
}
41+
this.addEventListeners();
42+
this.updatePosition();
6143
}
6244

63-
setContentWidth(instance) {
64-
// box-sizing: border-box
65-
const content = instance.popper.querySelector('.tippy-content');
66-
if (content) {
67-
content.style.width = `${instance.reference.offsetWidth}px`;
45+
close() {
46+
this.hide();
47+
}
48+
49+
hide() {
50+
if (!this.openValue) return;
51+
this.openValue = false;
52+
this.contentTarget.classList.add("hidden");
53+
this.contentTarget.dataset.state = "closed";
54+
this.removeEventListeners();
55+
this.deselectAll();
56+
if (this.cleanup) {
57+
this.cleanup();
58+
this.cleanup = null;
6859
}
6960
}
7061

71-
handleContextMenu(event) {
72-
event.preventDefault();
73-
this.open();
62+
updatePosition() {
63+
if (this.cleanup) this.cleanup();
64+
65+
this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {
66+
computePosition(this.triggerTarget, this.contentTarget, {
67+
placement: this.optionsValue.placement || "bottom-start",
68+
middleware: [offset(4), flip(), shift({ padding: 8 })],
69+
}).then(({ x, y, placement }) => {
70+
Object.assign(this.contentTarget.style, {
71+
left: `${x}px`,
72+
top: `${y}px`,
73+
});
74+
this.contentTarget.dataset.side = placement.split("-")[0];
75+
});
76+
});
7477
}
7578

76-
open() {
77-
this.tippy.show();
79+
addEventListeners() {
80+
document.addEventListener("keydown", this.boundHandleKeydown);
81+
document.addEventListener("click", this.handleOutsidePointer);
82+
// A right-click outside should dismiss this menu and let the native
83+
// context menu (or another trigger's menu) take over.
84+
document.addEventListener("contextmenu", this.handleOutsidePointer);
7885
}
7986

80-
close() {
81-
this.tippy.hide();
87+
removeEventListeners() {
88+
document.removeEventListener("keydown", this.boundHandleKeydown);
89+
document.removeEventListener("click", this.handleOutsidePointer);
90+
document.removeEventListener("contextmenu", this.handleOutsidePointer);
8291
}
8392

93+
handleOutsidePointer = (event) => {
94+
if (!this.element.contains(event.target)) {
95+
this.hide();
96+
}
97+
};
98+
8499
handleKeydown(e) {
85-
// return if no menu items (one line fix for)
86-
if (this.menuItemTargets.length === 0) { return; }
100+
if (e.key === "Escape") {
101+
e.preventDefault();
102+
this.hide();
103+
return;
104+
}
87105

88-
if (e.key === 'ArrowDown') {
106+
if (this.menuItemTargets.length === 0) return;
107+
108+
if (e.key === "ArrowDown") {
89109
e.preventDefault();
90110
this.updateSelectedItem(1);
91-
} else if (e.key === 'ArrowUp') {
111+
} else if (e.key === "ArrowUp") {
92112
e.preventDefault();
93113
this.updateSelectedItem(-1);
94-
} else if (e.key === 'Enter' && this.selectedIndex !== -1) {
114+
} else if (e.key === "Enter" && this.selectedIndex !== -1) {
95115
e.preventDefault();
96116
this.menuItemTargets[this.selectedIndex].click();
97117
}
98118
}
99119

100120
updateSelectedItem(direction) {
101-
// Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index
102121
this.menuItemTargets.forEach((item, index) => {
103-
if (item.getAttribute('aria-selected') === 'true') {
122+
if (item.getAttribute("aria-selected") === "true") {
104123
this.selectedIndex = index;
105124
}
106125
});
@@ -121,24 +140,17 @@ export default class extends Controller {
121140
}
122141

123142
toggleAriaSelected(element, isSelected) {
124-
// Add or remove attribute
125143
if (isSelected) {
126-
element.setAttribute('aria-selected', 'true');
144+
element.setAttribute("aria-selected", "true");
127145
} else {
128-
element.removeAttribute('aria-selected');
146+
element.removeAttribute("aria-selected");
129147
}
130148
}
131149

132150
deselectAll() {
133-
this.menuItemTargets.forEach(item => this.toggleAriaSelected(item, false));
151+
this.menuItemTargets.forEach((item) =>
152+
this.toggleAriaSelected(item, false)
153+
);
134154
this.selectedIndex = -1;
135155
}
136-
137-
addEventListeners() {
138-
document.addEventListener('keydown', this.boundHandleKeydown);
139-
}
140-
141-
removeEventListeners() {
142-
document.removeEventListener('keydown', this.boundHandleKeydown);
143-
}
144156
}

0 commit comments

Comments
 (0)