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',