fix: restore tooltips on icon-only buttons inside popover triggers (#5935)

Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Nikhil 2026-03-18 15:11:15 +05:30 committed by GitHub
parent f012c31ba2
commit 08921786e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 192 additions and 9 deletions

View file

@ -173,7 +173,7 @@
.tippy-box[data-theme~="tooltip"] { .tippy-box[data-theme~="tooltip"] {
@apply bg-primaryDark; @apply bg-primaryDark;
@apply border-solid; @apply border-solid border-divider;
@apply shadow-lg; @apply shadow-lg;
.tippy-content { .tippy-content {
@ -187,7 +187,7 @@
.tippy-svg-arrow { .tippy-svg-arrow {
svg:first-child { svg:first-child {
@apply fill-primaryDark; @apply fill-divider;
} }
svg:last-child { svg:last-child {
@ -220,7 +220,7 @@
.tippy-box[data-theme~="tooltip"] { .tippy-box[data-theme~="tooltip"] {
@apply bg-primaryLight; @apply bg-primaryLight;
@apply border-solid border-[#33363d]; @apply border-solid border-divider;
@apply shadow-lg; @apply shadow-lg;
.tippy-content { .tippy-content {
@ -234,7 +234,7 @@
.tippy-svg-arrow { .tippy-svg-arrow {
svg:first-child { svg:first-child {
@apply fill-primaryLight; @apply fill-divider;
} }
svg:last-child { svg:last-child {

View file

@ -132,6 +132,7 @@
"web_app": "Web App", "web_app": "Web App",
"cli": "CLI" "cli": "CLI"
}, },
"downloads": "Downloads",
"chat_with_us": "Chat with us", "chat_with_us": "Chat with us",
"contact_us": "Contact us", "contact_us": "Contact us",
"cookies": "Cookies", "cookies": "Cookies",

View file

@ -87,6 +87,8 @@
:on-shown="() => downloadableLinksRef.focus()" :on-shown="() => downloadableLinksRef.focus()"
> >
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('app.downloads')"
:icon="IconDownload" :icon="IconDownload"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark" class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
/> />

View file

@ -47,9 +47,9 @@
/> />
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="t('app.wiki')"
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing" to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
blank blank
:title="t('app.wiki')"
:icon="IconHelpCircle" :icon="IconHelpCircle"
/> />
<HoppButtonSecondary <HoppButtonSecondary

View file

@ -1,11 +1,26 @@
import { HoppModule } from "." import { HoppModule } from "."
import VueTippy, { roundArrow, setDefaultProps } from "vue-tippy" import type { DirectiveBinding, VNode } from "vue"
import VueTippy, { useTippy, roundArrow, setDefaultProps } from "vue-tippy"
import "tippy.js/dist/tippy.css" import "tippy.js/dist/tippy.css"
import "tippy.js/animations/scale-subtle.css" import "tippy.js/animations/scale-subtle.css"
import "tippy.js/dist/border.css" import "tippy.js/dist/border.css"
import "tippy.js/dist/svg-arrow.css" import "tippy.js/dist/svg-arrow.css"
// Extended HTMLElement with tippy instance properties added by vue-tippy
interface TippyElement extends HTMLElement {
$tippy?: {
props: Record<string, unknown>
setProps: (opts: Record<string, unknown>) => void
destroy: () => void
}
_tippy?: {
props: Record<string, unknown>
setProps: (opts: Record<string, unknown>) => void
destroy: () => void
}
}
export type TippyState = { export type TippyState = {
isEnabled: boolean isEnabled: boolean
isVisible: boolean isVisible: boolean
@ -14,9 +29,86 @@ export type TippyState = {
isShown: boolean isShown: boolean
} }
// Resolves tooltip options from directive binding, vnode props, and DOM attrs.
// Reads from vnode.props first (immune to DOM stripping by parent <tippy>
// components), then falls back to DOM attributes.
// Pure — does not mutate the DOM. Callers are responsible for removing the
// title attribute to suppress the browser's native tooltip.
function resolveOpts(
el: TippyElement,
binding: DirectiveBinding,
vnode: VNode
): Record<string, unknown> {
const opts =
typeof binding.value === "string"
? { content: binding.value }
: { ...(binding.value || {}) }
if (!opts.content) {
const title = vnode.props?.title ?? el.getAttribute("title")
if (title) {
opts.content = title
}
}
if (!opts.content && el.getAttribute("content")) {
opts.content = el.getAttribute("content")
}
return opts
}
export default <HoppModule>{ export default <HoppModule>{
onVueAppInit(app) { onVueAppInit(app) {
app.use(VueTippy) // Register VueTippy with a noop directive name so the plugin's built-in
// v-tippy directive doesn't conflict with our custom one below.
app.use(VueTippy, { directive: "tippy-original" })
// Custom v-tippy directive: reads content from vnode.props instead of
// el.getAttribute('title') to fix tooltips inside <tippy> wrappers where
// tippy.js strips the title attribute before the child directive reads it.
// Fixes #5915
app.directive("tippy", {
mounted(el: TippyElement, binding: DirectiveBinding, vnode: VNode) {
const opts = resolveOpts(el, binding, vnode)
el.removeAttribute("title")
// useTippy (not tippy() directly) so setDefaultProps are applied.
// Works outside setup() because vue-tippy v6.x guards getCurrentInstance().
useTippy(el, opts)
},
// Explicit cleanup for v-if removals — useTippy's internal
// onBeforeUnmount only fires on component unmount, not element removal.
unmounted(el: TippyElement) {
if (el._tippy) {
el._tippy.destroy()
} else if (el.$tippy) {
el.$tippy.destroy()
}
},
// Sync tooltip content when reactive :title bindings change
// (e.g. "Add star" ↔ "Remove star" in History).
updated(el: TippyElement, binding: DirectiveBinding, vnode: VNode) {
const tippy = el.$tippy || el._tippy
if (!tippy) return
const opts = resolveOpts(el, binding, vnode)
if (!opts.content) {
opts.content = ""
}
// Vue re-applies :title on each patch cycle
el.removeAttribute("title")
// Skip if content unchanged — setProps is expensive (recreates Popper).
const currentContent = tippy.props?.content
if (opts.content === currentContent) return
tippy.setProps(opts)
},
})
setDefaultProps({ setDefaultProps({
animation: "scale-subtle", animation: "scale-subtle",

View file

@ -1,14 +1,102 @@
import { HoppModule } from '.'; import { HoppModule } from '.';
import VueTippy, { roundArrow, setDefaultProps } from 'vue-tippy'; import type { DirectiveBinding, VNode } from 'vue';
import VueTippy, { useTippy, roundArrow, setDefaultProps } from 'vue-tippy';
import 'tippy.js/dist/tippy.css'; import 'tippy.js/dist/tippy.css';
import 'tippy.js/animations/scale-subtle.css'; import 'tippy.js/animations/scale-subtle.css';
import 'tippy.js/dist/border.css'; import 'tippy.js/dist/border.css';
import 'tippy.js/dist/svg-arrow.css'; import 'tippy.js/dist/svg-arrow.css';
interface TippyElement extends HTMLElement {
$tippy?: {
props: Record<string, unknown>;
setProps: (opts: Record<string, unknown>) => void;
destroy: () => void;
};
_tippy?: {
props: Record<string, unknown>;
setProps: (opts: Record<string, unknown>) => void;
destroy: () => void;
};
}
// Resolves tooltip options from directive binding, vnode props, and DOM attrs.
// Reads from vnode.props first (immune to DOM stripping by parent <tippy>
// components), then falls back to DOM attributes.
function resolveOpts(
el: TippyElement,
binding: DirectiveBinding,
vnode: VNode,
): Record<string, unknown> {
const opts =
typeof binding.value === 'string'
? { content: binding.value }
: { ...(binding.value || {}) };
if (!opts.content) {
const title = vnode.props?.title ?? el.getAttribute('title');
if (title) {
opts.content = title;
}
}
if (!opts.content && el.getAttribute('content')) {
opts.content = el.getAttribute('content');
}
return opts;
}
export default <HoppModule>{ export default <HoppModule>{
onVueAppInit(app) { onVueAppInit(app) {
app.use(VueTippy); // Register VueTippy under a noop directive name so it doesn't conflict
// with the custom v-tippy below.
app.use(VueTippy, { directive: 'tippy-original' });
// Custom v-tippy directive: reads content from vnode.props instead of
// el.getAttribute('title') to fix tooltips inside <tippy> wrappers where
// tippy.js strips the title attribute before the child directive reads it.
app.directive('tippy', {
mounted(el: TippyElement, binding: DirectiveBinding, vnode: VNode) {
const opts = resolveOpts(el, binding, vnode);
el.removeAttribute('title');
// useTippy (not tippy() directly) so setDefaultProps are applied.
// Works outside setup() because vue-tippy v6.x guards getCurrentInstance().
useTippy(el, opts);
},
// Explicit cleanup for v-if removals — useTippy's internal
// onBeforeUnmount only fires on component unmount, not element removal.
unmounted(el: TippyElement) {
if (el._tippy) {
el._tippy.destroy();
} else if (el.$tippy) {
el.$tippy.destroy();
}
},
// Sync tooltip content when reactive :title bindings change
// (e.g. "Add star" ↔ "Remove star" in History).
updated(el: TippyElement, binding: DirectiveBinding, vnode: VNode) {
const tippy = el.$tippy || el._tippy;
if (!tippy) return;
const opts = resolveOpts(el, binding, vnode);
if (!opts.content) {
opts.content = '';
}
// Vue re-applies :title on each patch cycle
el.removeAttribute('title');
// Skip if content unchanged — setProps is expensive (recreates Popper).
const currentContent = tippy.props?.content;
if (opts.content === currentContent) return;
tippy.setProps(opts);
},
});
setDefaultProps({ setDefaultProps({
animation: 'scale-subtle', animation: 'scale-subtle',