mirror of
https://github.com/ecomfe/vue-echarts.git
synced 2025-10-28 03:25:02 +08:00
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:
@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./api";
|
||||
export * from "./autoresize";
|
||||
export * from "./loading";
|
||||
export * from "./slot";
|
||||
|
||||
@ -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
146
src/composables/slot.ts
Normal 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>
|
||||
>;
|
||||
11
src/types.ts
11
src/types.ts
@ -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;
|
||||
|
||||
33
src/utils.ts
33
src/utils.ts
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user