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:
parent
f012c31ba2
commit
08921786e7
6 changed files with 192 additions and 9 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue