diff --git a/packages/hoppscotch-common/assets/themes/tippy-themes.scss b/packages/hoppscotch-common/assets/themes/tippy-themes.scss
index db1ce75c..7aa669bf 100644
--- a/packages/hoppscotch-common/assets/themes/tippy-themes.scss
+++ b/packages/hoppscotch-common/assets/themes/tippy-themes.scss
@@ -173,7 +173,7 @@
.tippy-box[data-theme~="tooltip"] {
@apply bg-primaryDark;
- @apply border-solid;
+ @apply border-solid border-divider;
@apply shadow-lg;
.tippy-content {
@@ -187,7 +187,7 @@
.tippy-svg-arrow {
svg:first-child {
- @apply fill-primaryDark;
+ @apply fill-divider;
}
svg:last-child {
@@ -220,7 +220,7 @@
.tippy-box[data-theme~="tooltip"] {
@apply bg-primaryLight;
- @apply border-solid border-[#33363d];
+ @apply border-solid border-divider;
@apply shadow-lg;
.tippy-content {
@@ -234,7 +234,7 @@
.tippy-svg-arrow {
svg:first-child {
- @apply fill-primaryLight;
+ @apply fill-divider;
}
svg:last-child {
diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json
index f91583a4..a4c26cba 100644
--- a/packages/hoppscotch-common/locales/en.json
+++ b/packages/hoppscotch-common/locales/en.json
@@ -132,6 +132,7 @@
"web_app": "Web App",
"cli": "CLI"
},
+ "downloads": "Downloads",
"chat_with_us": "Chat with us",
"contact_us": "Contact us",
"cookies": "Cookies",
diff --git a/packages/hoppscotch-common/src/components/app/Header.vue b/packages/hoppscotch-common/src/components/app/Header.vue
index 863a367e..79ed413c 100644
--- a/packages/hoppscotch-common/src/components/app/Header.vue
+++ b/packages/hoppscotch-common/src/components/app/Header.vue
@@ -87,6 +87,8 @@
:on-shown="() => downloadableLinksRef.focus()"
>
diff --git a/packages/hoppscotch-common/src/components/graphql/Query.vue b/packages/hoppscotch-common/src/components/graphql/Query.vue
index 034182ec..abf2afe3 100644
--- a/packages/hoppscotch-common/src/components/graphql/Query.vue
+++ b/packages/hoppscotch-common/src/components/graphql/Query.vue
@@ -47,9 +47,9 @@
/>
+ setProps: (opts: Record) => void
+ destroy: () => void
+ }
+ _tippy?: {
+ props: Record
+ setProps: (opts: Record) => void
+ destroy: () => void
+ }
+}
+
export type TippyState = {
isEnabled: boolean
isVisible: boolean
@@ -14,9 +29,86 @@ export type TippyState = {
isShown: boolean
}
+// Resolves tooltip options from directive binding, vnode props, and DOM attrs.
+// Reads from vnode.props first (immune to DOM stripping by parent
+// 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 {
+ 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 {
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 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({
animation: "scale-subtle",
diff --git a/packages/hoppscotch-sh-admin/src/modules/tippy.ts b/packages/hoppscotch-sh-admin/src/modules/tippy.ts
index a2149c3c..f50e3c56 100644
--- a/packages/hoppscotch-sh-admin/src/modules/tippy.ts
+++ b/packages/hoppscotch-sh-admin/src/modules/tippy.ts
@@ -1,14 +1,102 @@
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/animations/scale-subtle.css';
import 'tippy.js/dist/border.css';
import 'tippy.js/dist/svg-arrow.css';
+interface TippyElement extends HTMLElement {
+ $tippy?: {
+ props: Record;
+ setProps: (opts: Record) => void;
+ destroy: () => void;
+ };
+ _tippy?: {
+ props: Record;
+ setProps: (opts: Record) => 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
+// components), then falls back to DOM attributes.
+function resolveOpts(
+ el: TippyElement,
+ binding: DirectiveBinding,
+ vnode: VNode,
+): Record {
+ 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 {
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 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({
animation: 'scale-subtle',