feat: rendering tooltips and dataView with slots (#838)

* feat: experimental component rendered tooltip

* revert slot in VChart

* feat: use tooltip composable

* feat: try createApp

* feat: use pie chart as tooltip

* feat: switch to createVNode

The limitation is that the tooltip detached from the current component tree, not provide/inject

will try teleport next

* feat: try component with teleport

* wip

* add xAxis example

* refactor with shallowReactive

* Support dynamic slot

* fix: fill empty elements with object in array

* shallow copy option along the path

* ssr friendly

* vibe docs

* typo

* update according to the review

* add dataView slot

* chore: fix warnings and errors in demo (#839)

* chore: suppress warning in demo

* chore: prevent multiple intializations of esbuild-wasm in demo HMR

* feat: dynamically update the theme (#841)

Co-authored-by: GU Yiling <justice360@gmail.com>

* feat: add dataView slot

* vibe docs

---------

Co-authored-by: GU Yiling <justice360@gmail.com>

* fix docs typo

* update according to the review

* small fix

* remove wrapper around slotProp

* update comments

* remove anys

* add tooltip slot prop type

* target to vue 3.3

* move slot related codes to slot.ts

---------

Co-authored-by: GU Yiling <justice360@gmail.com>
This commit is contained in:
Yue JIN
2025-07-26 01:30:58 +08:00
committed by GU Yiling
parent 4beaa9bce9
commit a6ad4e70a2
13 changed files with 513 additions and 45 deletions

View File

@ -10,6 +10,7 @@ import {
h,
nextTick,
watchEffect,
toValue,
} from "vue";
import { init as initChart } from "echarts/core";
@ -19,9 +20,10 @@ import {
autoresizeProps,
useLoading,
loadingProps,
type PublicMethods,
useSlotOption,
} from "./composables";
import { isOn, omitOn, toValue } from "./utils";
import type { PublicMethods, SlotsTypes } from "./composables";
import { isOn, omitOn } from "./utils";
import { register, TAG_NAME } from "./wc";
import type { PropType, InjectionKey } from "vue";
@ -64,8 +66,9 @@ export default defineComponent({
...loadingProps,
},
emits: {} as unknown as Emits,
slots: Object as SlotsTypes,
inheritAttrs: false,
setup(props, { attrs, expose }) {
setup(props, { attrs, expose, slots }) {
const root = shallowRef<EChartsElement>();
const chart = shallowRef<EChartsType>();
const manualOption = shallowRef<Option>();
@ -93,6 +96,15 @@ export default defineComponent({
const listeners: Map<{ event: string; once?: boolean; zr?: boolean }, any> =
new Map();
const { teleportedSlots, patchOption } = useSlotOption(slots, () => {
if (!manualUpdate.value && props.option && chart.value) {
chart.value.setOption(
patchOption(props.option),
realUpdateOptions.value,
);
}
});
// We are converting all `on<Event>` props and collect them into `listeners` so that
// we can bind them to the chart instance later.
// For `onNative:<event>` props, we just strip the `Native:` part and collect them into
@ -174,7 +186,7 @@ export default defineComponent({
function commit() {
const opt = option || realOption.value;
if (opt) {
instance.setOption(opt, realUpdateOptions.value);
instance.setOption(patchOption(opt), realUpdateOptions.value);
}
}
@ -204,7 +216,7 @@ export default defineComponent({
if (!chart.value) {
init(option);
} else {
chart.value.setOption(option, updateOptions || {});
chart.value.setOption(patchOption(option), updateOptions);
}
};
@ -234,7 +246,7 @@ export default defineComponent({
if (!chart.value) {
init();
} else {
chart.value.setOption(option, {
chart.value.setOption(patchOption(option), {
// mutating `option` will lead to `notMerge: false` and
// replacing it with new reference will lead to `notMerge: true`
notMerge: option !== oldOption,
@ -312,11 +324,15 @@ export default defineComponent({
// This type casting ensures TypeScript correctly types the exposed members
// that will be available when using this component.
return (() =>
h(TAG_NAME, {
...nonEventAttrs.value,
...nativeListeners,
ref: root,
class: ["echarts", ...(nonEventAttrs.value.class || [])],
})) as unknown as typeof exposed & PublicMethods;
h(
TAG_NAME,
{
...nonEventAttrs.value,
...nativeListeners,
ref: root,
class: ["echarts", nonEventAttrs.value.class],
},
teleportedSlots(),
)) as unknown as typeof exposed & PublicMethods;
},
});

View File

@ -1,3 +1,4 @@
export * from "./api";
export * from "./autoresize";
export * from "./loading";
export * from "./slot";

View File

@ -1,5 +1,4 @@
import { inject, computed, watchEffect } from "vue";
import { toValue } from "../utils";
import { inject, computed, watchEffect, toValue } from "vue";
import type { Ref, InjectionKey, PropType } from "vue";
import type {
@ -18,7 +17,7 @@ export function useLoading(
): void {
const defaultLoadingOptions = inject(LOADING_OPTIONS_KEY, {});
const realLoadingOptions = computed(() => ({
...(toValue(defaultLoadingOptions) || {}),
...toValue(defaultLoadingOptions),
...loadingOptions?.value,
}));

146
src/composables/slot.ts Normal file
View File

@ -0,0 +1,146 @@
import {
h,
Teleport,
onUpdated,
onUnmounted,
onMounted,
shallowRef,
shallowReactive,
warn,
} from "vue";
import type { Slots, SlotsType } from "vue";
import type { Option } from "../types";
import { isValidArrayIndex, isSameSet } from "../utils";
import type { TooltipComponentFormatterCallbackParams } from "echarts";
const SLOT_OPTION_PATHS = {
tooltip: ["tooltip", "formatter"],
dataView: ["toolbox", "feature", "dataView", "optionToContent"],
} as const;
type SlotPrefix = keyof typeof SLOT_OPTION_PATHS;
type SlotName = SlotPrefix | `${SlotPrefix}-${string}`;
type SlotRecord<T> = Partial<Record<SlotName, T>>;
const SLOT_PREFIXES = Object.keys(SLOT_OPTION_PATHS) as SlotPrefix[];
function isValidSlotName(key: string): key is SlotName {
return SLOT_PREFIXES.some(
(slotPrefix) => key === slotPrefix || key.startsWith(slotPrefix + "-"),
);
}
export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
const detachedRoot =
typeof window !== "undefined" ? document.createElement("div") : undefined;
const containers = shallowReactive<SlotRecord<HTMLElement>>({});
const initialized = shallowReactive<SlotRecord<boolean>>({});
const params = shallowReactive<SlotRecord<unknown>>({});
const isMounted = shallowRef(false);
// Teleport the slots to a detached root
const teleportedSlots = () => {
// Make slots client-side only to avoid SSR hydration mismatch
return isMounted.value
? h(
Teleport,
{ to: detachedRoot },
Object.entries(slots)
.filter(([key]) => isValidSlotName(key))
.map(([key, slot]) => {
const slotName = key as SlotName;
const slotContent = initialized[slotName]
? slot?.(params[slotName])
: undefined;
return h(
"div",
{
ref: (el) => (containers[slotName] = el as HTMLElement),
style: { display: "contents" },
},
slotContent,
);
}),
)
: undefined;
};
// Shallow-clone the option along the path and override the target callback
function patchOption(src: Option): Option {
const root = { ...src };
Object.keys(slots)
.filter((key) => {
const isValidSlot = isValidSlotName(key);
if (!isValidSlot) {
warn(`Invalid vue-echarts slot name: ${key}`);
}
return isValidSlot;
})
.forEach((key) => {
const path = key.split("-");
const prefix = path.shift() as SlotPrefix;
path.push(...SLOT_OPTION_PATHS[prefix]);
let cur: any = root;
for (let i = 0; i < path.length - 1; i++) {
const seg = path[i];
const next = cur[seg];
// Shallow-clone the link; create empty shell if missing
cur[seg] = next
? Array.isArray(next)
? [...next]
: { ...next }
: isValidArrayIndex(seg)
? []
: {};
cur = cur[seg];
}
cur[path[path.length - 1]] = (p: unknown) => {
initialized[key] = true;
params[key] = p;
return containers[key];
};
});
return root;
}
// `slots` is not reactive, so we need to watch it manually
let slotNames: SlotName[] = [];
onUpdated(() => {
const newSlotNames = Object.keys(slots).filter(isValidSlotName);
if (!isSameSet(newSlotNames, slotNames)) {
// Clean up states for removed slots
slotNames.forEach((key) => {
if (!newSlotNames.includes(key)) {
delete params[key];
delete initialized[key];
delete containers[key];
}
});
slotNames = newSlotNames;
onSlotsChange();
}
});
onMounted(() => {
isMounted.value = true;
});
onUnmounted(() => {
detachedRoot?.remove();
});
return {
teleportedSlots,
patchOption,
};
}
export type SlotsTypes = SlotsType<
Record<
"tooltip" | `tooltip-${string}`,
TooltipComponentFormatterCallbackParams
> &
Record<"dataView" | `dataView-${string}`, Option>
>;

View File

@ -1,17 +1,8 @@
import { init } from "echarts/core";
import type { SetOptionOpts, ECElementEvent, ElementEvent } from "echarts/core";
import type { Ref, ShallowRef, WritableComputedRef, ComputedRef } from "vue";
import type { MaybeRefOrGetter } from "vue";
export type MaybeRef<T = any> =
| T
| Ref<T>
| ShallowRef<T>
| WritableComputedRef<T>;
export type MaybeRefOrGetter<T = any> =
| MaybeRef<T>
| ComputedRef<T>
| (() => T);
export type Injection<T> = MaybeRefOrGetter<T | null>;
type InitType = typeof init;

View File

@ -1,6 +1,3 @@
import type { MaybeRefOrGetter } from "./types";
import { unref } from "vue";
type Attrs = Record<string, any>;
// Copied from
@ -19,13 +16,25 @@ export function omitOn(attrs: Attrs): Attrs {
return result;
}
// Copied from
// https://github.com/vuejs/core/blob/3cb4db21efa61852b0541475b4ddf57fdec4c479/packages/shared/src/general.ts#L49-L50
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const isFunction = (val: unknown): val is Function => typeof val === "function";
// Copied from
// https://github.com/vuejs/core/blob/3cb4db21efa61852b0541475b4ddf57fdec4c479/packages/reactivity/src/ref.ts#L246-L248
export function toValue<T>(source: MaybeRefOrGetter<T>): T {
return isFunction(source) ? source() : unref(source);
export function isValidArrayIndex(key: string): boolean {
const num = Number(key);
return (
Number.isInteger(num) &&
num >= 0 &&
num < Math.pow(2, 32) - 1 &&
String(num) === key
);
}
export function isSameSet<T>(a: T[], b: T[]): boolean {
const setA = new Set(a);
const setB = new Set(b);
if (setA.size !== setB.size) return false;
for (const val of setA) {
if (!setB.has(val)) return false;
}
return true;
}