Compare commits

..

21 Commits

Author SHA1 Message Date
36c3cb97a9 chore: release v8.0.0-beta.1 2025-08-10 23:37:53 +08:00
55c68b48b7 chore: release and publish from github actions (#850) 2025-08-10 23:31:34 +08:00
c232e71c47 chore: add echarts 6 features in codegen
* chore: add echarts 6 features in codegen

* update according to 772cf01859
2025-08-10 23:31:34 +08:00
e568005bb2 docs: update version, deps and docs for 8.0 beta (#849)
* chore: up version and deps

* chore: use pnpm CLI to get versions

* add note for echarts 6 upgrade guide

* remove docs script

let cdn to redirect for us

* Revert "remove docs script"

This reverts commit 3bc237db9100864f2813249ac1693735a658e646.

* update demo links
2025-08-10 23:31:23 +08:00
8ed975e09b feat!: inject style via constructable CSSStyleSheet and remove CSP entry (#847)
* chore: not inject inline css on server

* feat!: remove csp entry

* keep csp title in readme

* chore: switch to rolldown and tsdown

* update

* dedupe

* update according to review

* emphasize "both" in csp section

* load css with unplugin-raw

* change tsdown entry
2025-08-10 23:26:17 +08:00
570a26c262 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>
2025-08-10 23:26:17 +08:00
df640ebce6 chore: remove large mode for flight example (#845) 2025-08-10 23:26:17 +08:00
30e7934aab feat: dynamically update the theme (#841)
Co-authored-by: GU Yiling <justice360@gmail.com>
2025-08-10 23:26:12 +08:00
6155bbb409 chore: fix warnings and errors in demo (#839)
* chore: suppress warning in demo

* chore: prevent multiple intializations of esbuild-wasm in demo HMR
2025-08-10 23:22:04 +08:00
fa42af0723 refactor: use Web Components without native class support detection (#836) 2025-08-10 23:22:04 +08:00
8b7ef5e6e1 refactor: switch to generated .d.ts (#835)
* build: generate d.ts

* fix: preserve PublicMethods

* fix: avoid exposing types of attrs

* refactor: use existing setoption type

* fix: expose root and chart

* feat: use symbol as injection key

* chore: add comment for the type casting of the exposed
2025-08-10 23:22:04 +08:00
522dd7cc5c chore: ESLint Flat Config (#834)
* chore: eslint flat config

* chore: format

* update according to review

* chore: remove prettier config and format

* fix: move handler to script to bypass eslint

* chore: config eslint for lang=js block

* docs: add surrounding empty lines for code block

* chore: also minify css in csp build

* chore: publint
2025-08-10 23:22:04 +08:00
077bd3ec40 build: migrate demo from webpack to Vite (#832) 2025-08-10 23:22:04 +08:00
473fed37a2 chore: remove @vue/runtime-core from peerDependencies
@vue/runtime-core was added here for supporting typescript in vue < 2.7
2025-08-10 23:22:04 +08:00
7ae6892fe6 refactor: change listeners from object to Map 2025-08-10 23:22:03 +08:00
71c106ae29 refactor: rename realListeners to listeners 2025-08-10 23:22:03 +08:00
381489da2f docs: update provide/inject section 2025-08-10 23:22:03 +08:00
2fb0dc2233 refactor: simplify render function 2025-08-10 23:22:03 +08:00
b6c84aab7e feat: support getter in provide/inject 2025-08-10 23:22:03 +08:00
c6a1228c9d docs: remove vue 2 related content 2025-08-10 23:21:47 +08:00
9067505a3a feat!: remove vue 2 2025-08-10 23:19:04 +08:00
48 changed files with 924 additions and 5089 deletions

1
.github/CODEOWNERS vendored
View File

@ -1 +0,0 @@
* @ecomfe/vue-echarts

View File

@ -1,5 +1,5 @@
name: "🐞 Bug Report"
description: Create a bug report for Vue ECharts
description: Create a bug report for Vue-ECharts
body:
- type: markdown
attributes:
@ -9,14 +9,14 @@ body:
id: confirmation
attributes:
label: Confirmation
description: Before submitting this issue, please make sure that the problem only occurs in Vue ECharts and is not related to ECharts itself.
description: Before submitting this issue, please make sure that the problem only occurs in Vue-ECharts and is not related to ECharts itself.
options:
- label: I can confirm this problem is not reproducible with ECharts itself.
required: true
- type: dropdown
id: integration
attributes:
label: How are you introducing Vue ECharts into your project?
label: How are you introducing Vue-ECharts into your project?
options:
- ES Module imports
- "<script> tag"

View File

@ -1,5 +1,5 @@
name: "🐞 Bug 报告"
description: 给 Vue ECharts 报告 bug
description: 给 Vue-ECharts 报告 bug
body:
- type: markdown
attributes:
@ -9,14 +9,14 @@ body:
id: confirmation
attributes:
label: 请确认
description: 在提交此问题前,请确认问题仅在 Vue ECharts 中发生,而与 ECharts 本身无关。
description: 在提交此问题前,请确认问题仅在 Vue-ECharts 中发生,而与 ECharts 本身无关。
options:
- label: 我可以确认这个问题无法在 ECharts 项目本身中复现。
required: true
- type: dropdown
id: integration
attributes:
label: 您是如何将 Vue ECharts 引入项目的?
label: 您是如何将 Vue-ECharts 引入项目的?
options:
- 通过 ES 模块 import
- "<script> 标签"

View File

@ -1,5 +1,5 @@
name: "✨ Feature Request"
description: Create a feature request for Vue ECharts
description: Create a feature request for Vue-ECharts
body:
- type: markdown
attributes:

View File

@ -1,5 +1,5 @@
name: "✨ 新功能建议"
description: 给 Vue ECharts 提交新功能建议
description: 给 Vue-ECharts 提交新功能建议
body:
- type: markdown
attributes:

View File

@ -1,55 +1,38 @@
name: CI
permissions:
contents: read
on:
pull_request:
push:
branches:
- main
- "8.0" # remove this after 8.0 is merged into main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Install Playwright
run: pnpm run test:setup:ci
- name: Lint
run: pnpm run lint
- name: Typecheck
run: pnpm run typecheck && pnpm run dev:typecheck
- name: Test
run: pnpm run test:coverage
- name: Build
run: pnpm run build
- name: Publint
run: pnpm run publint
- name: Upload coverage to Codecov
if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage/browser/lcov.info
disable_search: true

View File

@ -16,13 +16,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
@ -30,9 +30,6 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Install Playwright
run: pnpm run test:setup:ci
- name: Extract release notes
run: pnpm releaselog --format=notes ${{ github.ref_name }} > RELEASE_NOTES.md
@ -44,9 +41,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Test
run: pnpm run test
- name: Build
run: pnpm run build

2
.gitignore vendored
View File

@ -1,8 +1,6 @@
.DS_Store
node_modules
/dist
/coverage
/tests/__screenshots__
# local env files

View File

@ -1,21 +0,0 @@
# Repository Guidelines
## Project Structure & Module Organization
Core source lives in `src/`, implemented in TypeScript with the Vue 3 Composition API. Key entry modules include `src/index.ts` for exports, `src/ECharts.ts` for the main component, and utilities under `src/composables/` and `src/utils.ts`. Demo site assets sit in `demo/` (Vite-powered) and should be kept in sync with new features. Bundled artifacts in `dist/` are generated by `pnpm build`; avoid editing them manually. Shared build helpers reside in `scripts/`.
## Build, Test, and Development Commands
Use `pnpm install` to set up dependencies. `pnpm dev` serves the demo playground at `http://localhost:5173` for interactive testing. Run `pnpm build` (tsdown) to produce distributable output under `dist/`, and `pnpm dev:build` or `pnpm dev:preview` when you need the Vite bundle for the demo. `pnpm typecheck` validates the librarys TypeScript contracts, `pnpm lint` applies ESLint with autofix, `pnpm format` runs Prettier, and `pnpm test` executes the Vitest suite. Use `pnpm publint` before releases to confirm the published surface.
## Coding Style & Naming Conventions
This project targets Vue 3 + TypeScript with ECMAScript modules. Follow the existing 2-space indentation, trailing commas where valid, and single quotes for strings unless interpolation is required. Components and exported composables use PascalCase (e.g., `VChart`), while local helpers remain camelCase. Honor `eslint.config.ts` and Prettier defaults; always run `pnpm lint && pnpm format` before sending patches. Keep public exports centralized in `src/index.ts` and add accompanying CSS changes to `src/style.css`.
## Testing Guidelines
For complete and up-to-date testing and CI guidance, see [`tests/TESTING.md`](tests/TESTING.md).
## Commit & Pull Request Guidelines
Commit history follows Conventional Commits (`type(scope): summary`), e.g., `feat(runtime): add renderer option` or `chore(deps): update vue`. Use concise, imperative summaries and group related changes together. PRs should describe user-facing effects, list verification commands, and link issues with `Fixes #123` when applicable. Include screenshots or GIFs for visual updates to the demo, and note any doc changes (`README.md`, `demo/`) in the description. Ensure CI checks mirror local commands: `pnpm lint`, `pnpm typecheck`, and `pnpm build`.

View File

@ -1,11 +1,3 @@
## 8.0.0-beta.3
- Made CSS `border-radius` work out-of-the-box.
## 8.0.0-beta.2
- Added [smart update](https://github.com/ecomfe/vue-echarts/blob/main/README.md#smart-update) strategy to make updates more effortless.
## 8.0.0-beta.1
### Breaking changes
@ -293,7 +285,7 @@
- Update peer dependency for `echarts` to `^5.0.2`.
- Update peer dependency for `vue` to `^2.6.11 || ^3.0.0`.
- Now `@vue/composition-api` is required to be installed to use Vue ECharts with Vue 2.
- Now `@vue/composition-api` is required to be installed to use Vue-ECharts with Vue 2.
- `options` is renamed to **`option`** to align with ECharts itself.
- Updating `option` will respect **`update-options`** configs instead of checking reference change.
- `watch-shallow` is removed. Use **`manual-update`** for performance critical scenarios.

View File

@ -1,8 +1,10 @@
<p align="center"><a href="https://vue-echarts.dev/"><img alt="Vue ECharts" src="https://raw.githubusercontent.com/ecomfe/vue-echarts/refs/heads/main/demo/public/favicon.svg" width="96"></a></p>
<h1 align="center">Vue ECharts</h1>
<h1 align="center">Vue-ECharts</h1>
<p align="center">Vue.js component for Apache ECharts™.</p>
<p align="center"><a href="https://npmjs.com/package/vue-echarts"><img alt="npm version" src="https://img.shields.io/npm/v/vue-echarts"></a> <a href="https://vue-echarts.dev/"><img src="https://img.shields.io/badge/Demo%20%C2%BB-20c3aa" alt="View demo"></a> <a href="./README.zh-Hans.md"><img src="https://img.shields.io/badge/%E4%B8%AD%E6%96%87%E7%89%88%20%C2%BB-000" alt="前往中文版"></a></p>
<p align="center"><a href="https:///pr.new/ecomfe/vue-echarts"><img alt="Open in Codeflow" src="https://developer.stackblitz.com/img/open_in_codeflow.svg" height="28"></a> <a href="https://codesandbox.io/p/github/ecomfe/vue-echarts"><img alt="Edit in CodeSandbox" src="https://assets.codesandbox.io/github/button-edit-lime.svg" height="28"></a></p>
---
> Still using Vue 2? Read v7 docs [here →](https://github.com/ecomfe/vue-echarts/tree/7.x)
@ -17,7 +19,7 @@ npm install echarts vue-echarts
#### Example
<details>
<summary><a href="https://stackblitz.com/edit/vue-echarts-8?file=src%2FApp.vue">Demo →</a></summary>
<summary>Vue 3 <a href="https://stackblitz.com/edit/vue-echarts-8?file=src%2FApp.vue">Demo →</a></summary>
```vue
<template>
@ -112,17 +114,17 @@ import "echarts";
Drop `<script>` inside your HTML file and access the component via `window.VueECharts`.
<details>
<summary><a href="https://stackblitz.com/edit/vue-echarts-8-global?file=index.html">Demo →</a></summary>
<summary>Vue 3 <a href="https://stackblitz.com/edit/vue-echarts-8-global?file=index.html">Demo →</a></summary>
<!-- scripts:start -->
<!-- vue3Scripts:start -->
```html
<script src="https://cdn.jsdelivr.net/npm/echarts@6.0.0"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.21"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-echarts@8.0.0-beta.3"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.18"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-echarts@8.0.0-beta.1"></script>
```
<!-- scripts:end -->
<!-- vue3Scripts:end -->
```js
const app = Vue.createApp(...)
@ -151,17 +153,14 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
- `option: object`
ECharts' universal interface. Modifying this prop triggers Vue ECharts to compute an update plan and call `setOption`. Read more [here →](https://echarts.apache.org/en/option.html)
ECharts' universal interface. Modifying this prop will trigger ECharts' `setOption` method. Read more [here →](https://echarts.apache.org/en/option.html)
#### Smart Update
- If you supply `update-options` (via prop or injection), Vue ECharts forwards it directly to `setOption` and skips the planner.
- Manual `setOption` calls (only available when `manual-update` is `true`) behave like native ECharts, honouring only the per-call override you pass in.
- Otherwise, Vue ECharts analyses the change: removed objects become `null`, removed arrays become `[]` with `replaceMerge`, ID/anonymous deletions trigger `replaceMerge`, and risky changes fall back to `notMerge: true`.
> [!TIP]
> When `update-options` is not specified, `notMerge: false` will be specified by default when the `setOption` method is called if the `option` object is modified directly and the reference remains unchanged; otherwise, if a new reference is bound to `option`, `notMerge: true` will be specified.
- `update-options: object`
Options for updating chart option. If supplied (or injected), Vue ECharts forwards it directly to `setOption`, skipping the [smart update](#smart-update). See `echartsInstance.setOption`'s `opts` parameter [here →](https://echarts.apache.org/en/api.html#echartsInstance.setOption)
Options for updating chart option. See `echartsInstance.setOption`'s `opts` parameter [here →](https://echarts.apache.org/en/api.html#echartsInstance.setOption)
Injection key: `UPDATE_OPTIONS_KEY`.
@ -185,7 +184,7 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
- `manual-update: boolean` (default: `false`)
For performance critical scenarios (having a large dataset) we'd better bypass Vue's reactivity system for `option` prop. By specifying `manual-update` prop with `true` and not providing `option` prop, the dataset won't be watched any more. After doing so, you need to retrieve the component instance with `ref` and manually call `setOption` method to update the chart (manual `setOption` calls are ignored when `manual-update` is `false`).
For performance critical scenarios (having a large dataset) we'd better bypass Vue's reactivity system for `option` prop. By specifying `manual-update` prop with `true` and not providing `option` prop, the dataset won't be watched any more. After doing so, you need to retrieve the component instance with `ref` and manually call `setOption` method to update the chart.
### Events
@ -200,7 +199,7 @@ You can bind events with Vue's `v-on` directive.
> [!NOTE]
> Only the `.once` event modifier is supported as other modifiers are tightly coupled with the DOM event system.
Vue ECharts support the following events:
Vue-ECharts support the following events:
- `highlight` [](https://echarts.apache.org/en/api.html#events.highlight)
- `downplay` [](https://echarts.apache.org/en/api.html#events.downplay)
@ -250,7 +249,7 @@ See supported events [here →](https://echarts.apache.org/en/api.html#events)
#### Native DOM Events
As Vue ECharts binds events to the ECharts instance by default, there is some caveat when using native DOM events. You need to prefix the event name with `native:` to bind native DOM events.
As Vue-ECharts binds events to the ECharts instance by default, there is some caveat when using native DOM events. You need to prefix the event name with `native:` to bind native DOM events.
```vue
<template>
@ -260,7 +259,7 @@ As Vue ECharts binds events to the ECharts instance by default, there is some ca
### Provide / Inject
Vue ECharts provides provide/inject API for `theme`, `init-options`, `update-options` and `loading-options` to help configuring contextual options. eg. for `theme` you can use the provide API like this:
Vue-ECharts provides provide/inject API for `theme`, `init-options`, `update-options` and `loading-options` to help configuring contextual options. eg. for `theme` you can use the provide API like this:
<details>
<summary>Composition API</summary>
@ -334,11 +333,11 @@ export default {
> The following ECharts instance methods aren't exposed because their functionality is already provided by component [props](#props):
>
> - [`showLoading`](https://echarts.apache.org/en/api.html#echartsInstance.showLoading) / [`hideLoading`](https://echarts.apache.org/en/api.html#echartsInstance.hideLoading): use the `loading` and `loading-options` props instead.
> - [`setTheme`](https://echarts.apache.org/en/api.html#echartsInstance.setTheme): use the `theme` prop instead.
> - `setTheme`: use the `theme` prop instead.
### Slots
Vue ECharts allows you to define ECharts option's [`tooltip.formatter`](https://echarts.apache.org/en/option.html#tooltip.formatter) and [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/en/option.html#toolbox.feature.dataView.optionToContent) callbacks via Vue slots instead of defining them in your `option` object. This simplifies custom HTMLElement rendering using familiar Vue templating.
Vue-ECharts allows you to define ECharts option's [`tooltip.formatter`](https://echarts.apache.org/en/option.html#tooltip.formatter) and [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/en/option.html#toolbox.feature.dataView.optionToContent) callbacks via Vue slots instead of defining them in your `option` object. This simplifies custom HTMLElement rendering using familiar Vue templating.
**Slot Naming Convention**
@ -436,8 +435,6 @@ pnpm dev
Open `http://localhost:5173` to see the demo.
For testing and CI details, see [`tests/TESTING.md`](tests/TESTING.md).
## Notice
The Apache Software Foundation [Apache ECharts, ECharts](https://echarts.apache.org/), Apache, the Apache feather, and the Apache ECharts project logo are either registered trademarks or trademarks of the [Apache Software Foundation](https://www.apache.org/).

View File

@ -1,8 +1,10 @@
<p align="center"><a href="https://vue-echarts.dev/"><img alt="Vue ECharts" src="https://raw.githubusercontent.com/ecomfe/vue-echarts/refs/heads/main/demo/public/favicon.svg" width="96"></a></p>
<h1 align="center">Vue ECharts</h1>
<h1 align="center">Vue-ECharts</h1>
<p align="center">Apache ECharts™ 的 Vue.js 组件。</p>
<p align="center"><a href="https://npmjs.com/package/vue-echarts"><img alt="npm 版本" src="https://img.shields.io/npm/v/vue-echarts"></a> <a href="https://vue-echarts.dev/"><img src="https://img.shields.io/badge/%E6%BC%94%E7%A4%BA%20%C2%BB-20c3aa" alt="查看演示"></a></p>
<p align="center"><a href="https://npmjs.com/package/vue-echarts"><img alt="npm 版本" src="https://img.shields.io/npm/v/vue-echarts"></a> <a href="https://vue-echarts.dev/"><img src="https://img.shields.io/badge/%E6%BC%94%E7%A4%BA%20%C2%BB-20c3aa" alt="查看演示"></a> <a href="./README.zh-Hans.md"></p>
<p align="center"><a href="https:///pr.new/ecomfe/vue-echarts"><img alt="Open in Codeflow" src="https://developer.stackblitz.com/img/open_in_codeflow.svg" height="28"></a> <a href="https://codesandbox.io/p/github/ecomfe/vue-echarts"><img alt="Edit in CodeSandbox" src="https://assets.codesandbox.io/github/button-edit-lime.svg" height="28"></a></p>
---
> 还在使用 Vue 2可以继续阅读老版本的文档。[前往 →](https://github.com/ecomfe/vue-echarts/blob/7.x/README.zh-Hans.md)
@ -17,7 +19,7 @@ npm install echarts vue-echarts
#### 示例
<details>
<summary><a href="https://stackblitz.com/edit/vue-echarts-8?file=src%2FApp.vue">Demo →</a></summary>
<summary>Vue 3 <a href="https://stackblitz.com/edit/vue-echarts-8?file=src%2FApp.vue">Demo →</a></summary>
```vue
<template>
@ -112,17 +114,17 @@ import "echarts";
用如下方式在 HTML 中插入 `<script>` 标签,并且通过 `window.VueECharts` 来访问组件接口:
<details>
<summary><a href="https://stackblitz.com/edit/vue-echarts-8-global?file=index.html">Demo →</a></summary>
<summary>Vue 3 <a href="https://stackblitz.com/edit/vue-echarts-8-global?file=index.html">Demo →</a></summary>
<!-- scripts:start -->
<!-- vue3Scripts:start -->
```html
<script src="https://cdn.jsdelivr.net/npm/echarts@6.0.0"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.21"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-echarts@8.0.0-beta.3"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.18"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-echarts@8.0.0-beta.1"></script>
```
<!-- scripts:end -->
<!-- vue3Scripts:end -->
```js
const app = Vue.createApp(...)
@ -153,15 +155,12 @@ app.component('v-chart', VueECharts)
ECharts 的万能接口。修改这个 prop 会触发 ECharts 实例的 `setOption` 方法。查看[详情 →](https://echarts.apache.org/zh/option.html)
#### 智能更新
- 如果提供了 `update-options`(或通过 inject 注入Vue ECharts 会直接把它传给 `setOption`,不会执行智能计划。
- 手动调用 `setOption`(仅当 `manual-update``true` 时可用)与原生 ECharts 保持一致,只使用本次调用传入的参数。
- 其他情况下Vue ECharts 会分析差异:删除的对象写入 `null`,删除的数组写入 `[]` 并加入 `replaceMerge`ID/匿名项减少时追加 `replaceMerge`,风险较高的变更会退回 `notMerge: true`
> [!TIP]
> 在没有指定 `update-options` 时,如果直接修改 `option` 对象而引用保持不变,`setOption` 方法调用时将默认指定 `notMerge: false`;否则,如果为 `option` 绑定一个新的引用,将指定 `notMerge: true`。
- `update-options: object`
图表更新的配置项。一旦提供(或通过 inject 注入Vue ECharts 会直接把它传给 `setOption` 并跳过智能更新。请参考 `echartsInstance.setOption``opts` 参数。[前往 →](https://echarts.apache.org/zh/api.html#echartsInstance.setOption)
图表更新的配置项。请参考 `echartsInstance.setOption``opts` 参数。[前往 →](https://echarts.apache.org/zh/api.html#echartsInstance.setOption)
Inject 键名:`UPDATE_OPTIONS_KEY`
@ -185,7 +184,7 @@ app.component('v-chart', VueECharts)
- `manual-update: boolean`(默认值`false`
在性能敏感(数据量很大)的场景下,我们最好对于 `option` prop 绕过 Vue 的响应式系统。当将 `manual-update` 指定为 `true` 且不传入 `option` prop 时,数据将不会被监听。此时需要用 `ref` 获取组件实例手动调用 `setOption` 来更新图表(当 `manual-update``false` 时,手动调用 `setOption` 会被忽略)
在性能敏感(数据量很大)的场景下,我们最好对于 `option` prop 绕过 Vue 的响应式系统。当将 `manual-update` prop 指定为 `true` 且不传入 `option` prop 时,数据将不会被监听。然后,需要用 `ref` 获取组件实例以后手动调用 `setOption` 方法来更新图表。
### 事件
@ -200,7 +199,7 @@ app.component('v-chart', VueECharts)
> [!NOTE]
> 仅支持 `.once` 修饰符,因为其它修饰符都与 DOM 事件机制强耦合。
Vue ECharts 支持如下事件:
Vue-ECharts 支持如下事件:
- `highlight` [](https://echarts.apache.org/zh/api.html#events.highlight)
- `downplay` [](https://echarts.apache.org/zh/api.html#events.downplay)
@ -250,7 +249,7 @@ Vue ECharts 支持如下事件:
#### 原生 DOM 事件
由于 Vue ECharts 默认将事件绑定到 ECharts 实例,因此在使用原生 DOM 事件时需要做一些特殊处理。你需要在事件名称前加上 `native:` 前缀来绑定原生 DOM 事件。
由于 Vue-ECharts 默认将事件绑定到 ECharts 实例,因此在使用原生 DOM 事件时需要做一些特殊处理。你需要在事件名称前加上 `native:` 前缀来绑定原生 DOM 事件。
```vue
<template>
@ -260,7 +259,7 @@ Vue ECharts 支持如下事件:
### Provide / Inject
Vue ECharts 为 `theme``init-options``update-options``loading-options` 提供了 provide/inject API以通过上下文配置选项。例如可以通过如下方式来使用 provide API 为 `theme` 提供上下文配置:
Vue-ECharts 为 `theme``init-options``update-options``loading-options` 提供了 provide/inject API以通过上下文配置选项。例如可以通过如下方式来使用 provide API 为 `theme` 提供上下文配置:
<details>
<summary>组合式 API</summary>
@ -334,11 +333,11 @@ export default {
> 如下 ECharts 实例方法没有被暴露,因为它们的功能已经通过组件 [props](#props) 提供了:
>
> - [`showLoading`](https://echarts.apache.org/zh/api.html#echartsInstance.showLoading) / [`hideLoading`](https://echarts.apache.org/zh/api.html#echartsInstance.hideLoading):请使用 `loading` 和 `loading-options` prop。
> - [`setTheme`](https://echarts.apache.org/zh/api.html#echartsInstance.setTheme):请使用 `theme` prop。
> - `setTheme`:请使用 `theme` prop。
### 插槽Slots
Vue ECharts 允许你通过 Vue 插槽来定义 ECharts 配置中的 [`tooltip.formatter`](https://echarts.apache.org/zh/option.html#tooltip.formatter) 和 [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/zh/option.html#toolbox.feature.dataView.optionToContent) 回调,而无需在 `option` 对象中定义它们。你可以使用熟悉的 Vue 模板语法来编写自定义提示框或数据视图中的内容。
Vue-ECharts 允许你通过 Vue 插槽来定义 ECharts 配置中的 [`tooltip.formatter`](https://echarts.apache.org/zh/option.html#tooltip.formatter) 和 [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/zh/option.html#toolbox.feature.dataView.optionToContent) 回调,而无需在 `option` 对象中定义它们。你可以使用熟悉的 Vue 模板语法来编写自定义提示框或数据视图中的内容。
**插槽命名约定**
@ -436,8 +435,6 @@ pnpm dev
打开 `http://localhost:5173` 来查看 demo。
更多测试与 CI 说明请参见 [`tests/TESTING.md`](tests/TESTING.md)。
## 声明
The Apache Software Foundation [Apache ECharts, ECharts](https://echarts.apache.org/), Apache, the Apache feather, and the Apache ECharts project logo are either registered trademarks or trademarks of the [Apache Software Foundation](https://www.apache.org/).

View File

@ -54,7 +54,7 @@ watch(codeOpen, (open) => {
<logo-chart />
<h1>
<a href="https://github.com/ecomfe/vue-echarts">Vue ECharts</a>
<a href="https://github.com/ecomfe/vue-echarts">Vue-ECharts</a>
</h1>
<p class="desc">
Vue.js component for Apache ECharts. (<a

View File

@ -5,12 +5,11 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link
href="https://fonts.googleapis.com/css?family=Inter:300,500;display=swap"
rel="stylesheet"
/>
<title>Vue ECharts: Vue.js component for Apache ECharts™.</title>
<title>Vue-ECharts: Vue.js component for Apache ECharts™.</title>
</head>
<body>
<div id="app"></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><g clip-path="url(#a)"><path fill="#42B883" d="M12 0a12 12 0 1 0 0 24 12 12 0 0 0 0-24Zm-.792 5.307c2.192-.025 4.366 1.134 5.43 3.304.909 1.852.878 3.61-.098 5.645-.477.995-.487 1.06-.241 1.578.214.452.727.779 1.221.779.454 0 1.15-.586 1.252-1.054.1-.454-.193-1.118-.607-1.377a10.147 10.147 0 0 1-.393-.255c-.129-.1.42-.38.741-.38.687 0 1.247.526 1.375 1.29.055.333.134.422.44.502.859.222 1.297 1.451.755 2.116-.22.27-.23.271-.305.042-.267-.801-.666-1.12-1.403-1.12-.319 0-.572.128-1.098.556-1.006.82-1.866 1.303-2.907 1.632-1.276.384-2.752.478-4.086.156-2.162-.431-4.232-2.11-5.252-4.257C4.758 11.782 5.135 9 7.033 7.077a5.924 5.924 0 0 1 4.175-1.77Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h24v24H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 817 B

View File

@ -3,7 +3,6 @@
"include": ["./**/*", "./**/*.vue"],
"compilerOptions": {
"types": ["vite/client"],
"allowJs": true,
"noUncheckedIndexedAccess": false
"allowJs": true
}
}

View File

@ -1,6 +1,6 @@
{
"name": "vue-echarts",
"version": "8.0.0-beta.3",
"version": "8.0.0-beta.1",
"description": "Vue.js component for Apache ECharts™.",
"license": "MIT",
"repository": {
@ -11,22 +11,17 @@
"scripts": {
"dev": "vite",
"build": "tsdown",
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.vitest.json",
"typecheck": "tsc",
"lint": "eslint . --fix",
"format": "prettier . --write",
"publint": "publint",
"dev:build": "vite build",
"dev:preview": "vite preview",
"dev:typecheck": "vue-tsc -p ./demo",
"test:setup": "playwright install chromium",
"test:setup:ci": "playwright install --with-deps chromium",
"docs": "jiti ./scripts/docs.ts",
"release": "bumpp --execute \"pnpm run docs\" --all",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest watch"
"release": "pnpm run docs && bumpp --all"
},
"packageManager": "pnpm@10.17.0",
"packageManager": "pnpm@10.14.0",
"type": "module",
"main": "dist/index.js",
"unpkg": "dist/index.min.js",
@ -45,38 +40,33 @@
},
"devDependencies": {
"@highlightjs/vue-plugin": "^2.1.0",
"@types/node": "^22.17.1",
"@typescript-eslint/utils": "^8.39.1",
"@types/node": "^22.17.0",
"@typescript-eslint/utils": "^8.39.0",
"@vercel/analytics": "^1.5.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.0",
"@vue/tsconfig": "^0.7.0",
"@vueuse/core": "^13.6.0",
"bumpp": "^10.2.3",
"bumpp": "^10.2.2",
"comment-mark": "^2.0.1",
"echarts": "^6.0.0",
"echarts-gl": "^2.0.9",
"echarts-liquidfill": "^3.1.0",
"esbuild-wasm": "^0.25.9",
"eslint": "^9.33.0",
"esbuild-wasm": "^0.25.8",
"eslint": "^9.32.0",
"eslint-plugin-vue": "^10.4.0",
"highlight.js": "^11.11.1",
"jiti": "^2.5.1",
"pinia": "^3.0.3",
"playwright": "^1.55.0",
"postcss-nested": "^7.0.2",
"prettier": "^3.6.2",
"publint": "^0.3.12",
"releaselog": "^7.0.0",
"tsdown": "^0.15.0",
"releaselog": "^6.0.3",
"tsdown": "^0.13.3",
"typescript": "^5.9.2",
"unplugin-raw": "^0.6.0",
"vite": "npm:rolldown-vite@latest",
"vitest": "^3.2.4",
"vitest-browser-vue": "^1.1.0",
"unplugin-raw": "^0.5.1",
"vite": "npm:rolldown-vite@^7.1.0",
"vue": "^3.5.18",
"vue-tsc": "^3.0.5"
},
@ -85,9 +75,6 @@
"allowedVersions": {
"echarts": "6"
}
},
"overrides": {
"vite": "npm:rolldown-vite@latest"
}
}
}

2987
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

View File

@ -1,24 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"minimumReleaseAge": "7 days",
"internalChecksFilter": "strict",
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"groupName": "patches after 7 days",
"automerge": true
},
{
"matchUpdateTypes": ["minor"],
"excludePackageNames": ["vue", "echarts"],
"groupName": "minors after 7 days",
"automerge": false
},
{
"matchPackageNames": ["vue", "echarts"],
"matchUpdateTypes": ["minor"],
"automerge": false
}
]
}

View File

@ -42,7 +42,7 @@ README_FILES.forEach((file) => {
writeFileSync(
file,
commentMark(content, {
scripts: getCodeBlock(getScripts()),
vue3Scripts: getCodeBlock(getScripts()),
}),
"utf8",
);

View File

@ -11,10 +11,8 @@ import {
nextTick,
watchEffect,
toValue,
warn,
} from "vue";
import { init as initChart } from "echarts/core";
import type { EChartsOption } from "echarts";
import {
usePublicAPI,
@ -27,8 +25,6 @@ import {
import type { PublicMethods, SlotsTypes } from "./composables";
import { isOn, omitOn } from "./utils";
import { register, TAG_NAME } from "./wc";
import { planUpdate } from "./smart-update";
import type { Signature, UpdatePlan } from "./smart-update";
import type { PropType, InjectionKey } from "vue";
import type {
@ -75,19 +71,24 @@ export default defineComponent({
setup(props, { attrs, expose, slots }) {
const root = shallowRef<EChartsElement>();
const chart = shallowRef<EChartsType>();
const manualOption = shallowRef<Option>();
const defaultTheme = inject(THEME_KEY, null);
const defaultInitOptions = inject(INIT_OPTIONS_KEY, null);
const defaultUpdateOptions = inject(UPDATE_OPTIONS_KEY, null);
const { autoresize, manualUpdate, loading, loadingOptions } = toRefs(props);
const realOption = computed(() => props.option || {});
const realTheme = computed(() => props.theme || toValue(defaultTheme));
const realOption = computed(
() => manualOption.value || props.option || null,
);
const realTheme = computed(
() => props.theme || toValue(defaultTheme) || {},
);
const realInitOptions = computed(
() => props.initOptions || toValue(defaultInitOptions) || undefined,
() => props.initOptions || toValue(defaultInitOptions) || {},
);
const realUpdateOptions = computed(
() => props.updateOptions || toValue(defaultUpdateOptions),
() => props.updateOptions || toValue(defaultUpdateOptions) || {},
);
const nonEventAttrs = computed(() => omitOn(attrs));
const nativeListeners: Record<string, unknown> = {};
@ -97,60 +98,13 @@ export default defineComponent({
const { teleportedSlots, patchOption } = useSlotOption(slots, () => {
if (!manualUpdate.value && props.option && chart.value) {
applyOption(chart.value, props.option);
chart.value.setOption(
patchOption(props.option),
realUpdateOptions.value,
);
}
});
let lastSignature: Signature | undefined;
function resolveUpdateOptions(plan?: UpdatePlan): UpdateOptions {
const result: UpdateOptions = {};
const replacements = (plan?.replaceMerge ?? []).filter(
(key): key is string => key != null,
);
if (replacements.length > 0) {
result.replaceMerge = [...new Set(replacements)];
}
if (plan?.notMerge !== undefined) {
result.notMerge = plan.notMerge;
}
return result;
}
function applyOption(
instance: EChartsType,
option: Option,
override?: UpdateOptions,
manual = false,
) {
const patched = patchOption(option);
if (manual) {
instance.setOption(patched, override ?? {});
lastSignature = undefined;
return;
}
if (realUpdateOptions.value) {
const updateOptions = override ?? realUpdateOptions.value;
instance.setOption(patched, updateOptions);
lastSignature = undefined;
return;
}
const planned = planUpdate(
lastSignature,
patched as unknown as EChartsOption,
);
const updateOptions = resolveUpdateOptions(planned.plan);
instance.setOption(planned.option, updateOptions);
lastSignature = planned.signature;
}
// 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
@ -186,7 +140,7 @@ export default defineComponent({
listeners.set({ event, zr, once }, attrs[key]);
});
function init(option?: Option, manual = false, override?: UpdateOptions) {
function init(option?: Option) {
if (!root.value) {
return;
}
@ -210,13 +164,8 @@ export default defineComponent({
if (once) {
const raw = handler;
let called = false;
handler = (...args: any[]) => {
if (called) {
return;
}
called = true;
raw(...args);
target.off(event, handler);
};
@ -237,8 +186,7 @@ export default defineComponent({
function commit() {
const opt = option || realOption.value;
if (opt) {
applyOption(instance, opt, override, manual);
override = undefined;
instance.setOption(patchOption(opt), realUpdateOptions.value);
}
}
@ -258,21 +206,18 @@ export default defineComponent({
notMerge,
lazyUpdate?: boolean,
) => {
if (!props.manualUpdate) {
warn(
"[vue-echarts] setOption is only available when manual-update is true.",
);
return;
}
const updateOptions =
typeof notMerge === "boolean" ? { notMerge, lazyUpdate } : notMerge;
if (!chart.value) {
return;
if (props.manualUpdate) {
manualOption.value = option;
}
applyOption(chart.value, option, updateOptions ?? undefined, true);
if (!chart.value) {
init(option);
} else {
chart.value.setOption(patchOption(option), updateOptions);
}
};
function cleanup() {
@ -280,7 +225,6 @@ export default defineComponent({
chart.value.dispose();
chart.value = undefined;
}
lastSignature = undefined;
}
let unwatchOption: (() => void) | null = null;
@ -295,16 +239,20 @@ export default defineComponent({
if (!manualUpdate) {
unwatchOption = watch(
() => props.option,
(option) => {
(option, oldOption) => {
if (!option) {
lastSignature = undefined;
return;
}
if (!chart.value) {
return;
init();
} else {
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,
...realUpdateOptions.value,
});
}
applyOption(chart.value, option);
},
{ deep: true },
);
@ -329,7 +277,7 @@ export default defineComponent({
watch(
realTheme,
(theme) => {
chart.value?.setTheme(theme || {});
chart.value?.setTheme(theme);
},
{
deep: true,

View File

@ -10,7 +10,7 @@ import {
} from "vue";
import type { Slots, SlotsType } from "vue";
import type { Option } from "../types";
import { isBrowser, isValidArrayIndex, isSameSet } from "../utils";
import { isValidArrayIndex, isSameSet } from "../utils";
import type { TooltipComponentFormatterCallbackParams } from "echarts";
const SLOT_OPTION_PATHS = {
@ -29,7 +29,8 @@ function isValidSlotName(key: string): key is SlotName {
}
export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
const detachedRoot = isBrowser() ? document.createElement("div") : undefined;
const detachedRoot =
typeof window !== "undefined" ? document.createElement("div") : undefined;
const containers = shallowReactive<SlotRecord<HTMLElement>>({});
const initialized = shallowReactive<SlotRecord<boolean>>({});
const params = shallowReactive<SlotRecord<unknown>>({});
@ -38,7 +39,7 @@ export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
// Teleport the slots to a detached root
const teleportedSlots = () => {
// Make slots client-side only to avoid SSR hydration mismatch
return isMounted.value && detachedRoot
return isMounted.value
? h(
Teleport,
{ to: detachedRoot },

View File

@ -1,243 +0,0 @@
import type { Option } from "./types";
import { isPlainObject } from "./utils";
export interface UpdatePlan {
notMerge: boolean;
replaceMerge?: string[];
}
/** Summary of a top-level array key for deletion detection. */
export interface ArraySummary {
/** Unique, sorted string ids extracted from items' `id` field. */
idsSorted: string[];
/** Count of items without an `id` field. */
noIdCount: number;
}
/** Minimal signature of an option used to decide setOption behavior. */
export interface Signature {
/** Lengths of `option.options` and `option.media` (0 if not arrays). */
optionsLength: number;
mediaLength: number;
/** Map of array-typed top-level keys to their summaries. */
arrays: Record<string, ArraySummary | undefined>;
/** Sorted list of object-typed top-level keys. */
objects: string[];
/** Sorted list of scalar-typed top-level keys (string|number|boolean|null). */
scalars: string[];
}
/**
* Read an item's `id` as a string.
* Only accept string or number. Other types are ignored to surface inconsistent data early.
*/
function readId(item: unknown): string | undefined {
if (!isPlainObject(item)) {
return undefined;
}
const raw = (item as { id?: unknown }).id;
if (typeof raw === "string") {
return raw;
}
if (typeof raw === "number" && Number.isFinite(raw)) {
return String(raw);
}
return undefined;
}
/**
* Build a minimal signature from a full ECharts option.
* Only top-level keys are inspected.
*/
export function buildSignature(option: Option): Signature {
const opt = option as Record<string, unknown>;
const optionsLength = Array.isArray(opt.options)
? (opt.options as unknown[]).length
: 0;
const mediaLength = Array.isArray(opt.media)
? (opt.media as unknown[]).length
: 0;
const arrays: Record<string, ArraySummary | undefined> = Object.create(null);
const objects: string[] = [];
const scalars: string[] = [];
for (const key of Object.keys(opt)) {
if (key === "options" || key === "media") {
continue;
}
const value = opt[key];
if (Array.isArray(value)) {
const items = value as unknown[];
const ids = new Set<string>();
let noIdCount = 0;
for (let i = 0; i < items.length; i++) {
const id = readId(items[i]);
if (id !== undefined) {
ids.add(id);
} else {
noIdCount++;
}
}
const idsSorted = ids.size > 0 ? Array.from(ids).sort() : [];
arrays[key] = { idsSorted, noIdCount };
} else if (isPlainObject(value)) {
objects.push(key);
} else {
// scalar: string | number | boolean | null (undefined is treated as "absent")
if (value !== undefined) {
scalars.push(key);
}
}
}
if (objects.length > 1) {
objects.sort();
}
if (scalars.length > 1) {
scalars.sort();
}
return { optionsLength, mediaLength, arrays, objects, scalars };
}
function diffKeys(
prevKeys: readonly string[],
nextKeys: readonly string[],
): string[] {
if (prevKeys.length === 0) {
return [];
}
if (nextKeys.length === 0) {
return prevKeys.slice();
}
const nextSet = new Set(nextKeys);
const missing: string[] = [];
for (let i = 0; i < prevKeys.length; i++) {
const key = prevKeys[i];
if (!nextSet.has(key)) {
missing.push(key);
}
}
return missing;
}
function hasMissingIds(
prevIds: readonly string[],
nextIds: readonly string[],
): boolean {
if (prevIds.length === 0) {
return false;
}
if (nextIds.length === 0) {
return true;
}
const nextSet = new Set(nextIds);
for (let i = 0; i < prevIds.length; i++) {
if (!nextSet.has(prevIds[i])) {
return true;
}
}
return false;
}
export interface PlannedUpdate {
option: Option;
signature: Signature;
plan: UpdatePlan;
}
/**
* Produce an update plan plus a normalized option that encodes common deletions.
* Falls back to `notMerge: true` when the change looks complex.
*/
export function planUpdate(
prev: Signature | undefined,
option: Option,
): PlannedUpdate {
const next = buildSignature(option);
if (!prev) {
return { option, signature: next, plan: { notMerge: false } };
}
if (next.optionsLength < prev.optionsLength) {
return { option, signature: next, plan: { notMerge: true } };
}
if (next.mediaLength < prev.mediaLength) {
return { option, signature: next, plan: { notMerge: true } };
}
if (diffKeys(prev.scalars, next.scalars).length > 0) {
return { option, signature: next, plan: { notMerge: true } };
}
const replace = new Set<string>();
const overrides = new Map<string, null | []>();
const missingObjects = diffKeys(prev.objects, next.objects);
for (let i = 0; i < missingObjects.length; i++) {
overrides.set(missingObjects[i], null);
}
for (const key of Object.keys(prev.arrays)) {
const prevArray = prev.arrays[key];
if (!prevArray) {
continue;
}
const nextArray = next.arrays[key];
if (!nextArray) {
if (prevArray.idsSorted.length > 0 || prevArray.noIdCount > 0) {
overrides.set(key, []);
replace.add(key);
}
continue;
}
if (hasMissingIds(prevArray.idsSorted, nextArray.idsSorted)) {
replace.add(key);
continue;
}
if (nextArray.noIdCount < prevArray.noIdCount) {
replace.add(key);
}
}
let normalizedOption = option;
let signature = next;
if (overrides.size > 0) {
const clone = { ...(option as Record<string, unknown>) };
overrides.forEach((value, key) => {
clone[key] = value;
});
normalizedOption = clone as Option;
signature = buildSignature(normalizedOption);
}
const replaceMerge =
replace.size > 0 ? Array.from(replace).sort() : undefined;
const plan = replaceMerge
? { notMerge: false, replaceMerge }
: { notMerge: false };
return {
option: normalizedOption,
signature,
plan,
};
}

View File

@ -1,2 +1 @@
x-vue-echarts{display:block;width:100%;height:100%;min-width:0;}
x-vue-echarts>:first-child,x-vue-echarts>:first-child>canvas{border-radius:inherit;}
x-vue-echarts{display:block;width:100%;height:100%;min-width:0;}

View File

@ -1,9 +1,5 @@
type Attrs = Record<string, any>;
export function isBrowser(): boolean {
return typeof window !== "undefined" && typeof document !== "undefined";
}
// Copied from
// https://github.com/vuejs/vue-next/blob/5a7a1b8293822219283d6e267496bec02234b0bc/packages/shared/src/index.ts#L40-L41
const onRE = /^on[^a-z]/;
@ -42,7 +38,3 @@ export function isSameSet<T>(a: T[], b: T[]): boolean {
return true;
}
export function isPlainObject(v: unknown): v is Record<string, unknown> {
return v != null && typeof v === "object" && !Array.isArray(v);
}

View File

@ -1,5 +1,3 @@
import { isBrowser } from "./utils";
let registered: boolean | null = null;
export const TAG_NAME = "x-vue-echarts";
@ -13,33 +11,30 @@ export function register(): boolean {
return registered;
}
const registry = globalThis.customElements;
if (!isBrowser() || !registry?.get) {
registered = false;
return registered;
if (
typeof HTMLElement === "undefined" ||
typeof customElements === "undefined"
) {
return (registered = false);
}
if (!registry.get(TAG_NAME)) {
try {
class ECElement extends HTMLElement implements EChartsElement {
__dispose: (() => void) | null = null;
try {
class ECElement extends HTMLElement implements EChartsElement {
__dispose: (() => void) | null = null;
disconnectedCallback(): void {
if (this.__dispose) {
this.__dispose();
this.__dispose = null;
}
disconnectedCallback() {
if (this.__dispose) {
this.__dispose();
this.__dispose = null;
}
}
registry.define(TAG_NAME, ECElement);
} catch {
registered = false;
return registered;
}
if (customElements.get(TAG_NAME) == null) {
customElements.define(TAG_NAME, ECElement);
}
} catch {
return (registered = false);
}
registered = true;
return registered;
return (registered = true);
}

View File

@ -1,23 +0,0 @@
# Testing
We run Vitest in browser mode using Playwright (Chromium) with `vitest-browser-vue` to mount Vue components.
- Global setup: see `tests/setup.ts` (mocks `echarts/core`, resets DOM after each test).
- Prefer shared helpers under `tests/helpers/` to avoid duplicated setup.
- Test only public behavior; avoid internal implementation details.
- Keep tests deterministic: silence console noise and flush updates/animation frames with provided helpers.
## Run locally
- Install dependencies: `pnpm install`
- Install Chromium: `pnpm test:setup`
- Run tests: `pnpm test`
- Coverage (V8): `pnpm test:coverage`
- HTML report: `coverage/browser/index.html`
- LCOV: `coverage/browser/lcov.info`
## CI
- CI runs tests with coverage and uploads LCOV to Codecov (non-blocking).
- Chromium is installed via Playwright CLI with system deps: `pnpm exec playwright install --with-deps chromium`.
- Optional: restrict Codecov uploads to PRs and `main` via a workflow condition.

View File

@ -1,117 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { ref, type Ref } from "vue";
import { usePublicAPI, type PublicMethods } from "../src/composables/api";
import type { EChartsType } from "../src/types";
describe("usePublicAPI", () => {
it("throws until chart instance is available", () => {
const chart = ref<EChartsType | undefined>(undefined);
const api = usePublicAPI(chart as Ref<EChartsType | undefined>);
expect(() => api.getWidth()).toThrowError(
"ECharts is not initialized yet.",
);
const chartImpl = {
getWidth: vi.fn(() => 320),
getHeight: vi.fn(() => 180),
};
chart.value = chartImpl as unknown as EChartsType;
let width: number | undefined;
expect(() => {
width = api.getWidth();
}).not.toThrow();
expect(width).toBe(320);
expect(chartImpl.getWidth).toHaveBeenCalledTimes(1);
expect(chartImpl.getHeight).not.toHaveBeenCalled();
expect(api.getHeight()).toBe(180);
expect(chartImpl.getHeight).toHaveBeenCalledTimes(1);
});
it("forwards public calls to the ECharts instance", () => {
const methodNames = [
"getWidth",
"getHeight",
"getDom",
"getOption",
"resize",
"dispatchAction",
"convertToPixel",
"convertFromPixel",
"containPixel",
"getDataURL",
"getConnectedDataURL",
"appendData",
"clear",
"isDisposed",
"dispose",
] as const;
const chartImpl: Record<string, any> = { marker: "chart-instance" };
const callArgs: Record<string, any[]> = {};
methodNames.forEach((name) => {
chartImpl[name] = vi.fn(function (
this: Record<string, any>,
...args: any[]
) {
callArgs[name] = args;
expect(this.marker).toBe("chart-instance");
return `result:${name}`;
});
});
const chart = ref<EChartsType | undefined>();
chart.value = chartImpl as unknown as EChartsType;
const api = usePublicAPI(chart as Ref<EChartsType | undefined>);
const argsByName: Record<(typeof methodNames)[number], any[]> = {
getWidth: [],
getHeight: [],
getDom: [],
getOption: [],
resize: [{ width: 200, height: 100 }],
dispatchAction: [{ type: "highlight" }],
convertToPixel: ["grid", [0, 1]],
convertFromPixel: ["grid", [10, 20]],
containPixel: ["series", [1, 2]],
getDataURL: [],
getConnectedDataURL: [],
appendData: [{ seriesIndex: 0, data: [1, 2, 3] }],
clear: [],
isDisposed: [],
dispose: [],
};
methodNames.forEach((name) => {
const result = (
api[name as keyof PublicMethods] as (...args: any[]) => any
)(...argsByName[name]);
expect(result).toBe(`result:${name}`);
expect(chartImpl[name]).toHaveBeenCalledTimes(1);
expect(callArgs[name]).toEqual(argsByName[name]);
});
});
it("throws again if the chart instance is cleared after initialization", () => {
const chart = ref<EChartsType | undefined>();
const api = usePublicAPI(chart as Ref<EChartsType | undefined>);
const chartImpl = {
getWidth: vi.fn(() => 240),
};
chart.value = chartImpl as unknown as EChartsType;
expect(api.getWidth()).toBe(240);
expect(chartImpl.getWidth).toHaveBeenCalledTimes(1);
chart.value = undefined;
expect(() => api.getWidth()).toThrowError(
"ECharts is not initialized yet.",
);
});
});

View File

@ -1,184 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { ref, effectScope, nextTick, type Ref } from "vue";
import { throttle, resetECharts } from "./helpers/mock";
import { createSizedContainer, flushAnimationFrame } from "./helpers/dom";
import { useAutoresize } from "../src/composables/autoresize";
import type { AutoResize, EChartsType } from "../src/types";
describe("useAutoresize", () => {
beforeEach(() => {
resetECharts();
});
it("observes the root element and triggers resize on size change", async () => {
const resize = vi.fn();
const chart = ref<EChartsType | undefined>();
const autoresize = ref<AutoResize | undefined>(true);
const root = ref<HTMLElement | undefined>();
const observeSpy = vi.spyOn(window.ResizeObserver.prototype, "observe");
const disconnectSpy = vi.spyOn(
window.ResizeObserver.prototype,
"disconnect",
);
const container = createSizedContainer(120, 80);
const scope = effectScope();
scope.run(() => {
useAutoresize(
chart as Ref<EChartsType | undefined>,
autoresize as Ref<AutoResize | undefined>,
root as Ref<HTMLElement | undefined>,
);
});
chart.value = { resize } as unknown as EChartsType;
root.value = container;
await nextTick();
expect(observeSpy).toHaveBeenCalledWith(container);
await flushAnimationFrame();
expect(resize).not.toHaveBeenCalled();
container.style.width = "200px";
await flushAnimationFrame();
expect(resize).toHaveBeenCalledTimes(1);
scope.stop();
await flushAnimationFrame();
expect(disconnectSpy).toHaveBeenCalledTimes(1);
});
it("skips resize when autoresize is disabled or container is empty", async () => {
const resize = vi.fn();
const chart = ref<EChartsType | undefined>();
const autoresize = ref<AutoResize | undefined>();
const root = ref<HTMLElement | undefined>();
const observeSpy = vi.spyOn(window.ResizeObserver.prototype, "observe");
const container = createSizedContainer(0, 0);
const scope = effectScope();
scope.run(() => {
useAutoresize(
chart as Ref<EChartsType | undefined>,
autoresize as Ref<AutoResize | undefined>,
root as Ref<HTMLElement | undefined>,
);
});
chart.value = { resize } as unknown as EChartsType;
root.value = container;
await nextTick();
expect(observeSpy).not.toHaveBeenCalled();
expect(resize).not.toHaveBeenCalled();
autoresize.value = true;
await nextTick();
expect(observeSpy).toHaveBeenCalledWith(container);
container.style.height = "120px";
await flushAnimationFrame();
expect(resize).not.toHaveBeenCalled();
container.style.width = "160px";
await flushAnimationFrame();
expect(resize).toHaveBeenCalledTimes(1);
scope.stop();
});
it("invokes onResize callbacks and respects throttle options", async () => {
const resize = vi.fn();
const chart = ref<EChartsType | undefined>();
const onResize = vi.fn();
const autoresize = ref<AutoResize | undefined>({ throttle: 0, onResize });
const root = ref<HTMLElement | undefined>();
const container = createSizedContainer(80, 60);
const scope = effectScope();
scope.run(() => {
useAutoresize(
chart as Ref<EChartsType | undefined>,
autoresize as Ref<AutoResize | undefined>,
root as Ref<HTMLElement | undefined>,
);
});
chart.value = { resize } as unknown as EChartsType;
root.value = container;
await nextTick();
expect(vi.mocked(throttle)).not.toHaveBeenCalled();
container.style.height = "100px";
await flushAnimationFrame();
expect(resize).toHaveBeenCalledTimes(1);
expect(onResize).toHaveBeenCalledTimes(1);
autoresize.value = { throttle: 150 };
await nextTick();
expect(vi.mocked(throttle)).toHaveBeenCalledTimes(1);
const [, wait] = vi.mocked(throttle).mock.calls[0];
expect(wait).toBe(150);
scope.stop();
});
it("disconnects observer when autoresize toggles off and reactivates cleanly", async () => {
const resize = vi.fn();
const chart = ref<EChartsType | undefined>();
const autoresize = ref<AutoResize | undefined>(true);
const root = ref<HTMLElement | undefined>();
const observeSpy = vi.spyOn(window.ResizeObserver.prototype, "observe");
const disconnectSpy = vi.spyOn(
window.ResizeObserver.prototype,
"disconnect",
);
const container = createSizedContainer(140, 90);
const scope = effectScope();
scope.run(() => {
useAutoresize(
chart as Ref<EChartsType | undefined>,
autoresize as Ref<AutoResize | undefined>,
root as Ref<HTMLElement | undefined>,
);
});
chart.value = { resize } as unknown as EChartsType;
root.value = container;
await nextTick();
expect(observeSpy).toHaveBeenCalledTimes(1);
autoresize.value = false;
await nextTick();
expect(disconnectSpy).toHaveBeenCalledTimes(1);
expect(resize).not.toHaveBeenCalled();
autoresize.value = true;
await nextTick();
expect(observeSpy).toHaveBeenCalledTimes(2);
container.style.height = "120px";
await flushAnimationFrame();
expect(resize).toHaveBeenCalledTimes(1);
scope.stop();
});
});

View File

@ -1,685 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { defineComponent, h, nextTick, provide, ref, shallowRef } from "vue";
import { render } from "./helpers/testing";
import {
init,
enqueueChart,
resetECharts,
type ChartStub,
} from "./helpers/mock";
import type { UpdateOptions } from "../src/types";
import { withConsoleWarn } from "./helpers/dom";
import ECharts, { UPDATE_OPTIONS_KEY } from "../src/ECharts";
import { renderChart } from "./helpers/renderChart";
let chartStub: ChartStub;
beforeEach(() => {
resetECharts();
chartStub = enqueueChart();
});
describe("ECharts component", () => {
it("initializes and reacts to reactive props", async () => {
const option = ref({ title: { text: "coffee" } });
const group = ref("group-a");
const exposed = shallowRef<any>();
const screen = renderChart(
() => ({ option: option.value, group: group.value }),
exposed,
);
await nextTick();
expect(init).toHaveBeenCalledTimes(1);
const [rootEl, theme, initOptions] = init.mock.calls[0];
expect(rootEl).toBeInstanceOf(HTMLElement);
expect(theme).toBeNull();
expect(initOptions).toBeUndefined();
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
expect(chartStub.setOption.mock.calls[0][0]).toMatchObject({
title: { text: "coffee" },
});
expect(chartStub.group).toBe("group-a");
option.value = { title: { text: "latte" } };
await nextTick();
expect(chartStub.setOption).toHaveBeenCalledTimes(2);
expect(chartStub.setOption.mock.calls[1][0]).toMatchObject({
title: { text: "latte" },
});
group.value = "group-b";
await nextTick();
expect(chartStub.group).toBe("group-b");
screen.unmount();
await nextTick();
expect(chartStub.dispose).toHaveBeenCalledTimes(1);
});
it("exposes setOption for manual updates", async () => {
const optionRef = ref();
const exposed = shallowRef<any>();
renderChart(
() => ({ option: optionRef.value, manualUpdate: true }),
exposed,
);
await nextTick();
expect(typeof exposed.value?.setOption).toBe("function");
const manualOption = { series: [{ type: "bar", data: [1, 2, 3] }] };
exposed.value.setOption(manualOption);
expect(chartStub.setOption).toHaveBeenCalledTimes(2);
expect(chartStub.setOption.mock.calls[1][0]).toMatchObject(manualOption);
expect(chartStub.setOption.mock.calls[1][1]).toEqual({});
});
it("ignores setOption when manual-update is false", async () => {
const option = ref({ title: { text: "initial" } });
const exposed = shallowRef<any>();
renderChart(() => ({ option: option.value }), exposed);
await nextTick();
const initialCalls = chartStub.setOption.mock.calls.length;
withConsoleWarn((warnSpy) => {
exposed.value.setOption({ title: { text: "ignored" } }, true);
expect(chartStub.setOption).toHaveBeenCalledTimes(initialCalls);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[vue-echarts] setOption is only available"),
);
});
});
it("passes theme and initOptions props and reacts to theme changes", async () => {
const option = ref({ title: { text: "brew" } });
const theme = ref("dark");
const initOptions = ref({ renderer: "svg" });
const exposed = shallowRef<any>();
renderChart(
() => ({
option: option.value,
theme: theme.value,
initOptions: initOptions.value,
}),
exposed,
);
await nextTick();
const [rootEl, passedTheme, passedInit] = init.mock.calls[0];
expect(rootEl).toBeInstanceOf(HTMLElement);
expect(passedTheme).toBe("dark");
expect(passedInit).toEqual({ renderer: "svg" });
const currentStub = chartStub;
theme.value = { palette: ["#fff"] } as any;
await nextTick();
expect(currentStub.setTheme).toHaveBeenCalledWith({ palette: ["#fff"] });
});
it("re-initializes when initOptions change", async () => {
const option = ref({ title: { text: "coffee" } });
const initOptions = ref({ useDirtyRect: true });
const exposed = shallowRef<any>();
renderChart(
() => ({ option: option.value, initOptions: initOptions.value }),
exposed,
);
await nextTick();
const firstStub = chartStub;
const secondStub = enqueueChart();
chartStub = secondStub;
initOptions.value = { useDirtyRect: false };
await nextTick();
expect(firstStub.dispose).toHaveBeenCalledTimes(1);
expect(init).toHaveBeenCalledTimes(2);
expect(secondStub.setOption).toHaveBeenCalledTimes(1);
expect(secondStub.setOption.mock.calls[0][0]).toMatchObject({
title: { text: "coffee" },
});
});
it("passes updateOptions when provided", async () => {
const option = ref({ title: { text: "first" } });
const updateOptions = ref({ notMerge: true, replaceMerge: ["series"] });
const exposed = shallowRef<any>();
renderChart(
() => ({ option: option.value, updateOptions: updateOptions.value }),
exposed,
);
await nextTick();
expect(chartStub.setOption.mock.calls[0][1]).toBe(updateOptions.value);
chartStub.setOption.mockClear();
option.value = { title: { text: "second" } };
await nextTick();
expect(chartStub.setOption.mock.calls[0][1]).toBe(updateOptions.value);
});
it("switches between manual and reactive updates", async () => {
const option = ref({ title: { text: "initial" } });
const manualUpdate = ref(true);
const exposed = shallowRef<any>();
renderChart(
() => ({
option: option.value,
manualUpdate: manualUpdate.value,
}),
exposed,
);
await nextTick();
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
option.value = { title: { text: "manual" } };
await nextTick();
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
manualUpdate.value = false;
await nextTick();
option.value = { title: { text: "reactive" } };
await nextTick();
expect(chartStub.setOption).toHaveBeenCalledTimes(2);
expect(chartStub.setOption.mock.calls[1][0]).toMatchObject({
title: { text: "reactive" },
});
});
it("uses injected updateOptions defaults when not provided via props", async () => {
const option = ref({ series: [{ type: "bar", data: [1, 2] }] });
const defaults = ref<UpdateOptions>({
lazyUpdate: true,
replaceMerge: ["dataset"],
});
const exposed = shallowRef<any>();
const Root = defineComponent({
setup() {
provide(UPDATE_OPTIONS_KEY, () => defaults.value);
return () =>
h(ECharts, {
option: option.value,
ref: (value: unknown) => {
exposed.value = value;
},
});
},
});
render(Root);
await nextTick();
expect(chartStub.setOption.mock.calls[0][1]).toEqual({
lazyUpdate: true,
replaceMerge: ["dataset"],
});
chartStub.setOption.mockClear();
defaults.value = { notMerge: true };
option.value = { series: [{ type: "line", data: [3, 4] }] };
await nextTick();
expect(chartStub.setOption.mock.calls[0][1]).toEqual({ notMerge: true });
});
it("handles manual setOption when chart instance is missing", async () => {
const optionRef = ref({ title: { text: "initial" } });
const exposed = shallowRef<any>();
renderChart(
() => ({ option: optionRef.value, manualUpdate: true }),
exposed,
);
await nextTick();
const replacement = enqueueChart();
const initCallsBefore = init.mock.calls.length;
exposed.value.chart.value = undefined;
await nextTick();
const manualOption = { title: { text: "rehydrate" } };
exposed.value.setOption(manualOption);
expect(init.mock.calls.length).toBe(initCallsBefore);
expect(replacement.setOption).not.toHaveBeenCalled();
expect(exposed.value.chart.value).toBeUndefined();
});
it("ignores falsy reactive options", async () => {
const option = ref({ title: { text: "present" } });
const exposed = shallowRef<any>();
renderChart(() => ({ option: option.value }), exposed);
await nextTick();
const replacementStub = chartStub;
expect(replacementStub.setOption.mock.calls.length).toBeGreaterThan(0);
replacementStub.setOption.mockClear();
option.value = undefined as any;
await nextTick();
await nextTick();
expect(replacementStub.setOption).not.toHaveBeenCalled();
});
it("disposes chart on unmount when root element is unavailable", async () => {
const option = ref({ title: { text: "cleanup" } });
const exposed = shallowRef<any>();
const screen = renderChart(() => ({ option: option.value }), exposed);
await nextTick();
chartStub.dispose.mockClear();
exposed.value.root.value = undefined;
screen.unmount();
await nextTick();
expect(chartStub.dispose).toHaveBeenCalledTimes(1);
});
it("shows and hides loading based on props", async () => {
const option = ref({});
const loading = ref(true);
const loadingOptions = ref({ text: "Loading" });
const exposed = shallowRef<any>();
renderChart(
() => ({
option: option.value,
loading: loading.value,
loadingOptions: loadingOptions.value,
}),
exposed,
);
await nextTick();
expect(chartStub.showLoading).toHaveBeenCalledWith(
expect.objectContaining({ text: "Loading" }),
);
loading.value = false;
await nextTick();
expect(chartStub.hideLoading).toHaveBeenCalledTimes(1);
});
it("binds chart, zr, and native event listeners", async () => {
const clickHandler = vi.fn();
const nativeClick = vi.fn();
const zrMove = vi.fn();
const option = ref({});
const exposed = shallowRef<any>();
renderChart(
() => ({
option: option.value,
onClick: clickHandler,
"onNative:click": nativeClick,
"onZr:mousemoveOnce": zrMove,
}),
exposed,
);
await nextTick();
expect(chartStub.on).toHaveBeenCalledWith("click", expect.any(Function));
const chartListener = chartStub.on.mock.calls[0][1];
chartListener("payload");
expect(clickHandler).toHaveBeenCalledWith("payload");
const zr = chartStub.getZr();
expect(zr.on).toHaveBeenCalledWith("mousemove", expect.any(Function));
const zrListener = zr.on.mock.calls[0][1];
zrListener("zr-payload");
expect(zrMove).toHaveBeenCalledWith("zr-payload");
expect(zr.off).toHaveBeenCalledWith("mousemove", zrListener);
await nextTick();
const rootEl =
(exposed.value?.root?.value as HTMLElement | undefined) ??
(document.querySelector("x-vue-echarts") as HTMLElement | null);
expect(rootEl).toBeInstanceOf(HTMLElement);
rootEl!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(nativeClick).toHaveBeenCalledTimes(1);
});
it("removes once listeners after first invocation", async () => {
const clickOnce = vi.fn();
const zrOnce = vi.fn();
const option = ref({});
const exposed = shallowRef<any>();
renderChart(
() => ({
option: option.value,
onClickOnce: clickOnce,
"onZr:clickOnce": zrOnce,
}),
exposed,
);
await nextTick();
const chartCall = chartStub.on.mock.calls.find(
(call: any[]) => call[0] === "click",
);
expect(chartCall).toBeTruthy();
const chartListener = chartCall?.[1];
chartListener?.("payload");
chartListener?.("again");
expect(clickOnce).toHaveBeenCalledTimes(1);
expect(chartStub.off).toHaveBeenCalledWith("click", chartListener);
const zr = chartStub.getZr();
const zrCall = zr.on.mock.calls.find((call: any[]) => call[0] === "click");
expect(zrCall).toBeTruthy();
const zrListener = zrCall?.[1];
zrListener?.("zr");
zrListener?.("zr-again");
expect(zrOnce).toHaveBeenCalledTimes(1);
expect(zr.off).toHaveBeenCalledWith("click", zrListener);
});
it("plans replaceMerge when series id is removed", async () => {
const option = ref({
series: [
{ id: "a", type: "bar", data: [1] },
{ id: "b", type: "bar", data: [2] },
],
});
const exposed = shallowRef<any>();
renderChart(() => ({ option: option.value }), exposed);
await nextTick();
chartStub.setOption.mockClear();
// Remove one id to trigger replaceMerge planning
option.value = {
series: [{ id: "b", type: "bar", data: [3] }],
} as any;
await nextTick();
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
const updateOptions = chartStub.setOption.mock.calls[0][1];
expect(updateOptions).toEqual(
expect.objectContaining({ replaceMerge: ["series"] }),
);
});
it("calls resize before commit when autoresize is true", async () => {
const option = ref({ title: { text: "auto" } });
const exposed = shallowRef<any>();
renderChart(() => ({ option: option.value, autoresize: true }), exposed);
await nextTick();
expect(chartStub.resize).toHaveBeenCalled();
});
it("supports boolean notMerge in manual setOption", async () => {
const option = ref({ title: { text: "manual" } });
const exposed = shallowRef<any>();
renderChart(() => ({ option: option.value, manualUpdate: true }), exposed);
await nextTick();
chartStub.setOption.mockClear();
exposed.value.setOption({ title: { text: "b" } }, true, false);
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
const updateOptions = chartStub.setOption.mock.calls[0][1];
expect(updateOptions).toEqual({ notMerge: true, lazyUpdate: false });
});
it("applies empty object when theme becomes falsy", async () => {
const option = ref({});
const theme = ref({ palette: ["#000"] } as any);
const exposed = shallowRef<any>();
renderChart(() => ({ option: option.value, theme: theme.value }), exposed);
await nextTick();
const current = chartStub;
theme.value = undefined as any;
await nextTick();
expect(current.setTheme).toHaveBeenCalledWith({});
});
it("sets notMerge when options array shrinks", async () => {
const option = ref({ options: [{}, {}] } as any);
const exposed = shallowRef<any>();
renderChart(() => ({ option: option.value }), exposed);
await nextTick();
chartStub.setOption.mockClear();
option.value = { options: [{}] } as any;
await nextTick();
const updateOptions = chartStub.setOption.mock.calls[0][1];
expect(updateOptions).toEqual(expect.objectContaining({ notMerge: true }));
});
it("does not re-initialize when calling setOption with an existing instance (manual)", async () => {
const option = ref({ title: { text: "init-manual" } });
const exposed = shallowRef<any>();
renderChart(() => ({ option: option.value, manualUpdate: true }), exposed);
init.mockClear();
chartStub.setOption.mockClear();
exposed.value.setOption({ title: { text: "after" } });
await nextTick();
expect(init).not.toHaveBeenCalled();
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
});
it("applies option reactively without re-initialization when option becomes defined", async () => {
const option = ref<any>(null);
const exposed = shallowRef<any>();
renderChart(() => ({ option: option.value }), exposed);
init.mockClear();
chartStub.setOption.mockClear();
option.value = { title: { text: "now-defined" } };
await nextTick();
expect(init).not.toHaveBeenCalled();
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
});
it("honors override.replaceMerge in update options", async () => {
const option = ref({ series: [{ type: "bar", data: [1] }] });
const exposed = shallowRef<any>();
renderChart(() => ({ option: option.value, manualUpdate: true }), exposed);
await nextTick();
chartStub.setOption.mockClear();
exposed.value.setOption({ series: [{ type: "bar", data: [2] }] }, {
replaceMerge: ["series"],
} as any);
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
const updateOptions = chartStub.setOption.mock.calls[0][1];
expect(updateOptions).toEqual(
expect.objectContaining({ replaceMerge: ["series"] }),
);
});
it("merges base updateOptions from props during reactive updates", async () => {
const option = ref<any>({ title: { text: "merge-base" } });
const exposed = shallowRef<any>();
renderChart(
() => ({ option: option.value, updateOptions: { lazyUpdate: true } }),
exposed,
);
await nextTick();
chartStub.setOption.mockClear();
// Change option to trigger reactive update without special plan flags
option.value = { title: { text: "merge-base-2" } };
await nextTick();
const updateOptions = chartStub.setOption.mock.calls[0][1];
expect(updateOptions).toEqual(
expect.objectContaining({ lazyUpdate: true }),
);
});
it("sets __dispose on root during unmount when wcRegistered and cleanup runs via disconnectedCallback", async () => {
const option = ref({ title: { text: "wc-dispose" } });
const exposed = shallowRef<any>();
const screen = renderChart(() => ({ option: option.value }), exposed);
await nextTick();
const el: any =
(exposed.value?.root?.value as HTMLElement | undefined) ??
(document.querySelector("x-vue-echarts") as HTMLElement | null);
expect(el).toBeInstanceOf(HTMLElement);
chartStub.dispose.mockClear();
// Unmount triggers custom element disconnectedCallback, which invokes __dispose immediately
screen.unmount();
await nextTick();
expect(chartStub.dispose).toHaveBeenCalledTimes(1);
// wc disconnectedCallback should null out the hook after calling it
expect(el.__dispose).toBeNull();
});
it("setOption after unmount is a safe no-op (manual)", async () => {
const option = ref({ title: { text: "mounted" } });
const exposed = shallowRef<any>();
const screen = renderChart(
() => ({ option: option.value, manualUpdate: true }),
exposed,
);
await nextTick();
const callsBefore = chartStub.setOption.mock.calls.length;
// Capture the function reference before unmount; template ref becomes null on unmount
const callSetOption = exposed.value.setOption as (
opt: any,
notMerge?: any,
lazyUpdate?: any,
) => void;
// Unmount disposes and clears chart.value internally
screen.unmount();
await nextTick();
// Calling setOption after unmount should be a no-op and not throw
expect(() => callSetOption({ title: { text: "after" } })).not.toThrow();
expect(chartStub.setOption.mock.calls.length).toBe(callsBefore);
});
it("re-applies option when slot set changes (auto mode)", async () => {
const option = ref({ title: { text: "with-slots" } });
const showExtra = ref(true);
const exposed = shallowRef<any>();
const Root = defineComponent({
setup() {
return () =>
h(
ECharts,
{
option: option.value,
ref: (v: any) => (exposed.value = v),
},
showExtra.value
? {
tooltip: () => [h("span", "t")],
"tooltip-extra": () => [h("span", "x")],
}
: {
tooltip: () => [h("span", "t")],
},
);
},
});
render(Root);
await nextTick();
// One initial setOption from mount
const initialCalls = chartStub.setOption.mock.calls.length;
// Changing slot set triggers useSlotOption onChange, which applies current option again
showExtra.value = false;
await nextTick();
await nextTick();
expect(chartStub.setOption.mock.calls.length).toBeGreaterThan(initialCalls);
});
it("skips resize when instance is disposed in autoresize path", async () => {
const option = ref({});
const exposed = shallowRef<any>();
// Force the disposed branch in resize()
chartStub.isDisposed.mockReturnValue(true as any);
renderChart(() => ({ option: option.value, autoresize: true }), exposed);
await nextTick();
// resize should be skipped, commit should still apply option
expect(chartStub.resize).not.toHaveBeenCalled();
expect(chartStub.setOption).toHaveBeenCalled();
});
it("stops reactive updates after toggling manualUpdate to true", async () => {
const option = ref({ title: { text: "start" } });
const manual = ref(false);
const exposed = shallowRef<any>();
renderChart(
() => ({ option: option.value, manualUpdate: manual.value }),
exposed,
);
await nextTick();
chartStub.setOption.mockClear();
option.value = { title: { text: "reactive-1" } } as any;
await nextTick();
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
// Toggle to manual mode; watcher should be cleaned up (unwatchOption branch)
manual.value = true;
await nextTick();
chartStub.setOption.mockClear();
option.value = { title: { text: "reactive-2" } } as any;
await nextTick();
expect(chartStub.setOption).not.toHaveBeenCalled();
});
});

View File

@ -1,20 +0,0 @@
import { describe, it, expect } from "vitest";
import entry, * as moduleExports from "../src/index";
import globalEntry from "../src/global";
import ECharts from "../src/ECharts";
describe("entry points", () => {
it("re-export ECharts correctly from src/index.ts", () => {
expect(entry).toBe(ECharts);
expect(moduleExports.default).toBe(ECharts);
});
it("global entry merges default and named exports", () => {
expect(globalEntry.default).toBe(ECharts);
expect(Object.keys(globalEntry)).toEqual(
expect.arrayContaining(Object.keys(moduleExports)),
);
});
});

View File

@ -1,36 +0,0 @@
import { vi } from "vitest";
export function createSizedContainer(
width = 100,
height = 100,
): HTMLDivElement {
const element = document.createElement("div");
element.style.width = `${width}px`;
element.style.height = `${height}px`;
element.style.display = "block";
element.style.position = "relative";
document.body.appendChild(element);
return element;
}
export async function flushAnimationFrame(): Promise<void> {
await Promise.resolve();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
await Promise.resolve();
}
export function withConsoleWarn<T>(
callback: (warnSpy: ReturnType<typeof vi.spyOn>) => T,
): T {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
return callback(warnSpy);
} finally {
warnSpy.mockRestore();
}
}
export function resetDocumentBody(): void {
document.body.innerHTML = "";
}

View File

@ -1,90 +0,0 @@
import { vi } from "vitest";
type InitFn = (typeof import("echarts/core"))["init"];
type ThrottleFn = (typeof import("echarts/core"))["throttle"];
type Throttled = ReturnType<ThrottleFn>;
export const init = vi.fn<InitFn>();
export const throttle = vi.fn<ThrottleFn>();
export function createEChartsModule() {
return {
init,
throttle,
} satisfies Partial<Record<string, unknown>>;
}
export interface ChartStub {
setOption: ReturnType<typeof vi.fn>;
resize: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
isDisposed: ReturnType<typeof vi.fn>;
getZr: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
setTheme: ReturnType<typeof vi.fn>;
showLoading: ReturnType<typeof vi.fn>;
hideLoading: ReturnType<typeof vi.fn>;
group: string | undefined;
}
const queue: ChartStub[] = [];
let cursor = 0;
export function createChartStub(): ChartStub {
const zr = {
on: vi.fn(),
off: vi.fn(),
};
return {
setOption: vi.fn(),
resize: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn(() => false),
getZr: vi.fn(() => zr),
on: vi.fn(),
off: vi.fn(),
setTheme: vi.fn(),
showLoading: vi.fn(),
hideLoading: vi.fn(),
group: undefined,
};
}
function ensureStub(): ChartStub {
if (cursor >= queue.length) {
queue.push(createChartStub());
}
return queue[cursor++];
}
const defaultThrottleImplementation: ThrottleFn = ((fn: any) => {
const wrapped = ((...args: any[]) => fn(...args)) as Throttled;
(wrapped as any).clear = vi.fn();
(wrapped as any).dispose = vi.fn();
(wrapped as any).pending = vi.fn(() => false);
return wrapped;
}) as ThrottleFn;
export function resetECharts(): void {
queue.length = 0;
cursor = 0;
init.mockReset();
throttle.mockReset();
init.mockImplementation(((...args: Parameters<InitFn>) => {
void args;
return ensureStub() as unknown as ReturnType<InitFn>;
}) as InitFn);
throttle.mockImplementation(defaultThrottleImplementation);
}
export function enqueueChart(): ChartStub {
const stub = createChartStub();
queue.push(stub);
return stub;
}
resetECharts();

View File

@ -1,22 +0,0 @@
import { defineComponent, h, type Ref } from "vue";
import { render } from "vitest-browser-vue/pure";
import ECharts from "../../src/ECharts";
export type RenderChartProps = () => Record<string, unknown>;
export function renderChart(propsFactory: RenderChartProps, exposes: Ref<any>) {
const Root = defineComponent({
setup() {
return () =>
h(ECharts, {
...propsFactory(),
ref: (value: unknown) => {
exposes.value = value;
},
});
},
});
return render(Root);
}

View File

@ -1 +0,0 @@
export { cleanup, render } from "vitest-browser-vue/pure";

View File

@ -1,172 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { ref, nextTick, type Ref, defineComponent } from "vue";
import { cleanup, render } from "vitest-browser-vue/pure";
import { useLoading, LOADING_OPTIONS_KEY } from "../src/composables/loading";
import type {
EChartsType,
LoadingOptions,
LoadingOptionsInjection,
} from "../src/types";
afterEach(() => {
cleanup();
});
function renderUseLoading(
chart: Ref<EChartsType | undefined>,
loading: Ref<boolean | undefined>,
loadingOptions: Ref<LoadingOptions | undefined>,
defaults?: LoadingOptionsInjection,
) {
const Host = defineComponent({
setup() {
useLoading(chart, loading, loadingOptions);
return () => null;
},
});
const renderOptions = defaults
? {
global: {
provide: {
[LOADING_OPTIONS_KEY as symbol]: defaults,
},
},
}
: undefined;
return render(Host, renderOptions);
}
describe("useLoading", () => {
it("merges injected defaults with explicit options when showing loading", async () => {
const showLoading = vi.fn();
const hideLoading = vi.fn();
const chart = ref<EChartsType | undefined>();
const loading = ref<boolean | undefined>(false);
const loadingOptions = ref<LoadingOptions | undefined>({
text: "Loading...",
});
renderUseLoading(chart, loading, loadingOptions, () => ({
maskColor: "rgba(0,0,0,0.5)",
}));
chart.value = { showLoading, hideLoading } as unknown as EChartsType;
await nextTick();
expect(showLoading).not.toHaveBeenCalled();
expect(hideLoading).toHaveBeenCalledTimes(1);
hideLoading.mockClear();
loading.value = true;
await nextTick();
expect(showLoading).toHaveBeenCalledTimes(1);
expect(showLoading).toHaveBeenCalledWith({
maskColor: "rgba(0,0,0,0.5)",
text: "Loading...",
});
loading.value = false;
await nextTick();
expect(hideLoading).toHaveBeenCalledTimes(1);
});
it("does nothing until an instance is available", async () => {
const showLoading = vi.fn();
const hideLoading = vi.fn();
const chart = ref<EChartsType | undefined>();
const loading = ref<boolean | undefined>(true);
const loadingOptions = ref<LoadingOptions | undefined>({});
renderUseLoading(chart, loading, loadingOptions);
await nextTick();
expect(showLoading).not.toHaveBeenCalled();
expect(hideLoading).not.toHaveBeenCalled();
chart.value = { showLoading, hideLoading } as unknown as EChartsType;
await nextTick();
expect(showLoading).toHaveBeenCalledTimes(1);
expect(hideLoading).not.toHaveBeenCalled();
});
it("replays showLoading when injected defaults change while active", async () => {
const showLoading = vi.fn();
const hideLoading = vi.fn();
const chart = ref<EChartsType | undefined>();
const loading = ref<boolean | undefined>(true);
const loadingOptions = ref<LoadingOptions | undefined>({ text: "Loading" });
const defaults = ref({ color: "#fff" });
renderUseLoading(chart, loading, loadingOptions, () => defaults.value);
chart.value = { showLoading, hideLoading } as unknown as EChartsType;
await nextTick();
expect(showLoading).toHaveBeenCalledTimes(1);
expect(showLoading).toHaveBeenLastCalledWith({
color: "#fff",
text: "Loading",
});
expect(hideLoading).not.toHaveBeenCalled();
showLoading.mockClear();
defaults.value = { color: "#000" };
await nextTick();
expect(showLoading).toHaveBeenCalledTimes(1);
expect(showLoading).toHaveBeenLastCalledWith({
color: "#000",
text: "Loading",
});
expect(hideLoading).not.toHaveBeenCalled();
loading.value = false;
await nextTick();
expect(hideLoading).toHaveBeenCalledTimes(1);
expect(showLoading).not.toHaveBeenCalledTimes(2);
});
it("replays showLoading when explicit options change while active", async () => {
const showLoading = vi.fn();
const hideLoading = vi.fn();
const chart = ref<EChartsType | undefined>();
const loading = ref<boolean | undefined>(true);
const loadingOptions = ref<LoadingOptions | undefined>({
text: "Initial",
color: "#fff",
});
const defaults = ref({ maskColor: "rgba(0, 0, 0, 0.5)" });
renderUseLoading(chart, loading, loadingOptions, () => defaults.value);
chart.value = { showLoading, hideLoading } as unknown as EChartsType;
await nextTick();
expect(showLoading).toHaveBeenCalledTimes(1);
expect(showLoading).toHaveBeenLastCalledWith({
maskColor: "rgba(0, 0, 0, 0.5)",
text: "Initial",
color: "#fff",
});
expect(hideLoading).not.toHaveBeenCalled();
showLoading.mockClear();
loadingOptions.value = { text: "Updated", color: "#0f0" };
await nextTick();
expect(showLoading).toHaveBeenCalledTimes(1);
expect(showLoading).toHaveBeenLastCalledWith({
maskColor: "rgba(0, 0, 0, 0.5)",
text: "Updated",
color: "#0f0",
});
expect(hideLoading).not.toHaveBeenCalled();
});
});

View File

@ -1,14 +0,0 @@
import { afterEach, vi } from "vitest";
import { cleanup } from "vitest-browser-vue/pure";
import { createEChartsModule } from "./helpers/mock";
import { resetDocumentBody } from "./helpers/dom";
// Mock echarts/core globally for browser tests
vi.mock("echarts/core", () => createEChartsModule());
// Centralized cleanup for all browser tests
afterEach(() => {
cleanup();
resetDocumentBody();
});

View File

@ -1,245 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import {
defineComponent,
h,
nextTick,
ref,
shallowRef,
watchEffect,
type PropType,
} from "vue";
import { render } from "./helpers/testing";
import { useSlotOption } from "../src/composables/slot";
import { withConsoleWarn } from "./helpers/dom";
type SlotTestHandle = {
patchOption: ReturnType<typeof useSlotOption>["patchOption"];
teleportedSlots: ReturnType<typeof useSlotOption>["teleportedSlots"];
};
const SlotTestComponent = defineComponent({
props: {
onChange: {
type: Function as PropType<() => void>,
default: undefined,
},
},
setup(props, ctx) {
const { teleportedSlots, patchOption } = useSlotOption(
ctx.slots,
props.onChange ?? (() => {}),
);
ctx.expose({ patchOption, teleportedSlots });
return () => h("div", teleportedSlots());
},
});
type SlotDictionary = Record<string, (...args: any[]) => any>;
// cleanup and document reset are handled in tests/setup.ts
function renderSlotComponent(
slotFactory: () => SlotDictionary,
onChange?: () => void,
): { exposed: ReturnType<typeof shallowRef<SlotTestHandle | undefined>> } {
const exposed = shallowRef<SlotTestHandle>();
const Root = defineComponent({
setup() {
const componentRef = shallowRef<SlotTestHandle>();
watchEffect(() => {
if (componentRef.value) {
exposed.value = componentRef.value;
}
});
return () =>
h(
SlotTestComponent,
{
ref: (value: unknown) => {
componentRef.value = value as SlotTestHandle;
},
onChange,
},
slotFactory(),
);
},
});
render(Root);
return {
exposed,
};
}
describe("useSlotOption", () => {
it("returns a Teleport vnode after mount", async () => {
const { exposed } = renderSlotComponent(() => ({
tooltip: () => [h("span", "t")],
}));
// Component is mounted by the test renderer synchronously; teleportedSlots should return a Teleport VNode
const vnode: any = exposed.value!.teleportedSlots();
expect(vnode).toBeTruthy();
expect(vnode.type?.__isTeleport).toBe(true);
});
it("patches tooltip slots and renders teleported content", async () => {
const changeSpy = vi.fn();
const { exposed } = renderSlotComponent(
() => ({
tooltip: (params: any) => [h("span", `tooltip-${params?.dataIndex}`)],
}),
changeSpy,
);
await nextTick();
changeSpy.mockClear();
const patched: any = exposed.value!.patchOption({});
expect(changeSpy).not.toHaveBeenCalled();
expect(typeof patched.tooltip?.formatter).toBe("function");
const container = patched.tooltip!.formatter!({ dataIndex: 42 });
expect(container).toBeInstanceOf(HTMLElement);
await nextTick();
expect(container.textContent).toBe("tooltip-42");
});
it("patches dataView slots and renders teleported content", async () => {
const changeSpy = vi.fn();
const { exposed } = renderSlotComponent(
() => ({
dataView: () => [h("span", "data-view")],
}),
changeSpy,
);
await nextTick();
changeSpy.mockClear();
const patched: any = exposed.value!.patchOption({
toolbox: { feature: {} },
});
expect(changeSpy).not.toHaveBeenCalled();
const optionToContent = patched.toolbox?.feature?.dataView?.optionToContent;
expect(typeof optionToContent).toBe("function");
const container = optionToContent?.({});
expect(container).toBeInstanceOf(HTMLElement);
await nextTick();
expect(container?.textContent).toBe("data-view");
});
it("notifies when slot set changes and cleans state", async () => {
const changeSpy = vi.fn();
const showExtra = ref(true);
const { exposed } = renderSlotComponent(() => {
const slots: SlotDictionary = {
tooltip: (params: any) => [h("span", `tooltip-${params?.dataIndex}`)],
};
if (showExtra.value) {
slots["tooltip-extra"] = () => [h("span", "extra")];
}
return slots;
}, changeSpy);
await nextTick();
changeSpy.mockClear();
const patched: any = exposed.value!.patchOption({});
expect(typeof patched.tooltip?.formatter).toBe("function");
patched.tooltip!.formatter!({ dataIndex: 1 });
await nextTick();
showExtra.value = false;
await nextTick();
expect(changeSpy).toHaveBeenCalledTimes(1);
const patchedAfterRemoval: any = exposed.value!.patchOption({});
expect(patchedAfterRemoval["tooltip-extra"]).toBeUndefined();
});
it("warns and skips invalid slot names", async () => {
const changeSpy = vi.fn();
const { exposed } = renderSlotComponent(
() => ({
legend: () => [h("span", "legend")],
}),
changeSpy,
);
await nextTick();
changeSpy.mockClear();
withConsoleWarn((warnSpy) => {
const patched: any = exposed.value!.patchOption({});
const flattened = warnSpy.mock.calls.flat().join(" ");
expect(flattened).toContain("Invalid vue-echarts slot name: legend");
expect(patched.legend).toBeUndefined();
expect(changeSpy).not.toHaveBeenCalled();
});
});
it("clones existing array branches when patching series tooltip slots", async () => {
const { exposed } = renderSlotComponent(() => ({
"tooltip-series-0": () => [h("span", "series-0")],
}));
await nextTick();
const originalOption = {
series: [
{
tooltip: {},
},
],
};
const patched: any = exposed.value!.patchOption(originalOption);
expect(patched).not.toBe(originalOption);
expect(patched.series).not.toBe(originalOption.series);
const formatter = patched.series?.[0]?.tooltip?.formatter;
expect(typeof formatter).toBe("function");
const container = formatter?.({ dataIndex: 7 });
expect(container).toBeInstanceOf(HTMLElement);
await nextTick();
expect(container?.textContent).toBe("series-0");
});
it("creates array shells when target slot path is missing", async () => {
const { exposed } = renderSlotComponent(() => ({
"tooltip-series-1": () => [h("span", "series-1")],
}));
await nextTick();
const patched: any = exposed.value!.patchOption({});
const formatter = patched.series?.[1]?.tooltip?.formatter;
expect(typeof formatter).toBe("function");
const container = formatter?.({ dataIndex: 3 });
expect(container).toBeInstanceOf(HTMLElement);
await nextTick();
expect(container?.textContent).toBe("series-1");
});
});

View File

@ -1,369 +0,0 @@
import { describe, it, expect } from "vitest";
import { buildSignature, planUpdate } from "../src/smart-update";
import type { EChartsOption } from "echarts";
describe("smart-update", () => {
describe("buildSignature", () => {
it("collects scalars, objects, and array summaries", () => {
const option: EChartsOption = {
title: { text: "foo" },
tooltip: { show: true },
color: "#000",
dataset: [{ id: "ds1", source: [] }, { source: [] }],
series: [{ id: "a", type: "bar" }, { type: "line" }],
};
const signature = buildSignature(option);
expect(signature.objects).toEqual(["title", "tooltip"]);
expect(signature.scalars).toEqual(["color"]);
expect(signature.arrays.dataset?.idsSorted).toEqual(["ds1"]);
expect(signature.arrays.dataset?.noIdCount).toBe(1);
expect(signature.arrays.series?.idsSorted).toEqual(["a"]);
expect(signature.arrays.series?.noIdCount).toBe(1);
expect(signature.objects).not.toContain("color");
expect(signature.scalars).not.toContain("title");
expect(signature.arrays.tooltip).toBeUndefined();
});
it("treats numeric ids as strings and ignores unsupported ids", () => {
const option: EChartsOption = {
series: [
{ id: 2, type: "bar" },
{ id: 1, type: "line" },
{ id: { nested: true } as unknown, type: "pie" },
{ id: true as unknown as string, type: "scatter" },
{ type: "area" },
] as unknown as EChartsOption["series"],
};
const signature = buildSignature(option);
const summary = signature.arrays.series;
expect(summary?.idsSorted).toEqual(["1", "2"]);
expect(summary?.noIdCount).toBe(3);
});
});
describe("planUpdate", () => {
describe("bootstrap & neutral cases", () => {
it("returns neutral plan when previous signature missing", () => {
const option: EChartsOption = {
legend: { show: true },
series: [{ type: "bar", data: [1, 2, 3] }],
};
const result = planUpdate(undefined, option);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toBeUndefined();
expect(result.option).toEqual(option);
});
it("returns neutral plan when signatures match", () => {
const option: EChartsOption = {
title: { text: "foo" },
series: [{ id: "a" }],
};
const prev = buildSignature(option);
const next = planUpdate(prev, option);
expect(next.plan.notMerge).toBe(false);
expect(next.plan.replaceMerge).toBeUndefined();
expect(next.option).toEqual(option);
});
it("keeps merge when scalar value changes", () => {
const prev = buildSignature({ color: "red" });
const next = planUpdate(prev, { color: "blue" });
expect(next.plan.notMerge).toBe(false);
expect(next.plan.replaceMerge).toBeUndefined();
expect(next.option.color).toBe("blue");
});
it("keeps merge when new series IDs are added", () => {
const base: EChartsOption = {
series: [{ id: "latte", type: "bar", data: [10, 20] }],
};
const update: EChartsOption = {
series: [
{ id: "latte", type: "bar", data: [12, 24] },
{ id: "mocha", type: "bar", data: [14, 28] },
],
};
const result = planUpdate(buildSignature(base), update);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toBeUndefined();
expect(result.option.series).toEqual(update.series);
});
it("keeps merge when dataset items reorder without shrink", () => {
const prev = buildSignature({ dataset: [{ id: "a" }, { id: "b" }] });
const update: EChartsOption = {
dataset: [{ id: "b" }, { id: "a" }],
};
const result = planUpdate(prev, update);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toBeUndefined();
expect(result.option.dataset).toEqual(update.dataset);
});
});
describe("shrink detection", () => {
it("does not mark replace when previously empty array is removed", () => {
const base: EChartsOption = {
// empty array previously present
series: [] as any,
};
const update: EChartsOption = {
title: { text: "noop" },
// series key removed entirely
} as any;
const result = planUpdate(buildSignature(base), update);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toBeUndefined();
// Should not inject [] override since it was empty before
expect((result.option as any).series).toBeUndefined();
});
it("forces rebuild when options shrink", () => {
const prev = buildSignature({ options: [{}, {}] });
const { plan } = planUpdate(prev, { options: [{}] });
expect(plan.notMerge).toBe(true);
expect(plan.replaceMerge).toBeUndefined();
});
it("forces rebuild when media entries shrink", () => {
const prev = buildSignature({ media: [{}, {}] as any });
const { plan } = planUpdate(prev, { media: [{}] as any });
expect(plan.notMerge).toBe(true);
expect(plan.replaceMerge).toBeUndefined();
});
it("forces rebuild when scalars disappear", () => {
const prev = buildSignature({ color: "red", title: { text: "foo" } });
const { plan } = planUpdate(prev, { title: { text: "foo" } });
expect(plan.notMerge).toBe(true);
expect(plan.replaceMerge).toBeUndefined();
});
it("injects null for removed objects", () => {
const prev = buildSignature({ legend: { show: true } });
const next = planUpdate(prev, {});
expect(next.option.legend).toBeNull();
expect(next.plan.notMerge).toBe(false);
expect(next.plan.replaceMerge).toBeUndefined();
});
it("injects empty array and replaceMerge when array removed", () => {
const prev = buildSignature({ series: [{ id: "a" }, {}] });
const next = planUpdate(prev, {});
expect(next.option.series).toEqual([]);
expect(next.plan.replaceMerge).toEqual(["series"]);
expect(next.plan.notMerge).toBe(false);
});
it("adds replaceMerge when ids shrink", () => {
const prev = buildSignature({ series: [{ id: "a" }, { id: "b" }] });
const next = planUpdate(prev, { series: [{ id: "a" }] });
expect(next.plan.replaceMerge).toEqual(["series"]);
expect(next.plan.notMerge).toBe(false);
expect(next.option.series).toEqual([{ id: "a" }]);
});
it("adds replaceMerge when anonymous count shrinks", () => {
const prev = buildSignature({ series: [{}, {}] });
const next = planUpdate(prev, { series: [{}] });
expect(next.plan.replaceMerge).toEqual(["series"]);
expect(next.plan.notMerge).toBe(false);
expect(next.option.series).toEqual([{}]);
});
});
describe("real data scenarios", () => {
it("handles legend removal and series shrink", () => {
const base: EChartsOption = {
legend: { show: true },
dataset: [
{
id: "sales",
source: [
["product", "2015", "2016"],
["Matcha Latte", 43.3, 85.8],
],
},
],
series: [
{ id: "2015", type: "bar", datasetId: "sales" },
{ id: "2016", type: "bar", datasetId: "sales" },
],
};
const update: EChartsOption = {
dataset: [
{
id: "sales",
source: [
["product", "2015"],
["Matcha Latte", 55.1],
],
},
],
series: [{ id: "2015", type: "bar", datasetId: "sales" }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.legend).toBeNull();
expect(result.option.series).toEqual(update.series);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toEqual(["series"]);
expect(result.plan.replaceMerge).not.toContain("dataset");
});
it("clears dataset when removed entirely", () => {
const base: EChartsOption = {
dataset: [
{
id: "sales",
source: [
["product", "2015"],
["Latte", 30],
],
},
],
series: [{ id: "sales-series", type: "bar", datasetId: "sales" }],
};
const update: EChartsOption = {
series: [{ id: "sales-series", type: "bar", data: [35] }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.dataset).toEqual([]);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toContain("dataset");
expect(result.plan.replaceMerge).not.toContain("series");
});
it("tracks multiple array shrink operations", () => {
const base: EChartsOption = {
legend: { show: true },
dataset: [
{
id: "2015",
source: [
["Latte", 30],
["Mocha", 24],
],
},
{
id: "2016",
source: [
["Latte", 40],
["Mocha", 35],
],
},
],
series: [
{ id: "latte", type: "bar", datasetId: "2015" },
{ id: "mocha", type: "bar", datasetId: "2016" },
],
};
const update: EChartsOption = {
series: [{ id: "latte", type: "bar", datasetId: "2015" }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.legend).toBeNull();
expect(result.option.dataset).toEqual([]);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toEqual(["dataset", "series"]);
expect(result.plan.replaceMerge).not.toContain("legend");
});
it("injects null for tooltip removal while keeping explicit arrays", () => {
const base: EChartsOption = {
tooltip: { trigger: "axis" },
xAxis: [{ type: "category", data: ["Jan", "Feb"] }],
series: [{ type: "line", data: [10, 20] }],
};
const update: EChartsOption = {
xAxis: [{ type: "category", data: ["Jan", "Feb"] }],
series: [{ type: "line", data: [12, 18] }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.tooltip).toBeNull();
expect(result.option.xAxis).toEqual(update.xAxis);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toBeUndefined();
});
it("handles dataset to series data migration", () => {
const base: EChartsOption = {
dataset: [
{
id: "sales",
source: [
["Latte", 30],
["Mocha", 40],
],
},
],
series: [{ id: "sales", type: "bar", datasetId: "sales" }],
};
const update: EChartsOption = {
series: [{ id: "sales", type: "bar", data: [35, 44] }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.dataset).toEqual([]);
expect(result.option.series).toEqual(update.series);
expect(result.plan.replaceMerge).toEqual(["dataset"]);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).not.toContain("series");
});
it("tracks series ID removal while keeping modifications", () => {
const base: EChartsOption = {
series: [
{ id: "latte", type: "bar", data: [10, 20] },
{ id: "mocha", type: "bar", data: [15, 25] },
],
};
const update: EChartsOption = {
series: [{ id: "latte", type: "line", data: [11, 22] }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.series).toEqual(update.series);
expect(result.plan.replaceMerge).toEqual(["series"]);
expect(result.plan.notMerge).toBe(false);
expect(result.option.series).not.toEqual(base.series);
});
});
});
});

View File

@ -1,53 +0,0 @@
import { describe, it, expect, vi } from "vitest";
// Mock non-browser environment for this file only
vi.mock("/src/utils.ts", async (importOriginal: any) => {
const actual: any = await importOriginal();
return { ...actual, isBrowser: () => false };
});
import { h, defineComponent, shallowRef, watchEffect } from "vue";
import { render, cleanup } from "./helpers/testing";
import { useSlotOption } from "../src/composables/slot";
describe("SSR environment", () => {
it("slot: teleportedSlots undefined and formatter returns undefined", async () => {
const exposed = shallowRef<any>();
const Probe = defineComponent({
setup(_, ctx) {
const { teleportedSlots, patchOption } = useSlotOption(
ctx.slots,
() => {},
);
(ctx as any).expose({ teleportedSlots, patchOption });
return () => h("div", teleportedSlots());
},
});
const Root = defineComponent({
setup() {
const r = shallowRef<any>();
watchEffect(() => {
if (r.value) exposed.value = r.value;
});
return () =>
h(
Probe,
{ ref: (v: any) => (r.value = v) },
{ tooltip: () => [h("span", "x")] },
);
},
});
render(Root);
const vnode = exposed.value!.teleportedSlots();
expect(vnode).toBeUndefined();
const patched: any = exposed.value!.patchOption({});
const container = patched.tooltip?.formatter?.({ dataIndex: 0 });
expect(container).toBeUndefined();
cleanup();
});
});

View File

@ -1,38 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
describe("style entry", () => {
const adoptedDescriptor = Object.getOwnPropertyDescriptor(
Document.prototype,
"adoptedStyleSheets",
);
beforeEach(() => {
vi.resetModules();
document.head.innerHTML = "";
});
afterEach(() => {
if (adoptedDescriptor) {
Object.defineProperty(document, "adoptedStyleSheets", adoptedDescriptor);
} else {
delete (document as any).adoptedStyleSheets;
}
});
it("falls back to style tag when adoptedStyleSheets is unavailable", async () => {
Object.defineProperty(document, "adoptedStyleSheets", {
configurable: true,
value: undefined,
});
const replaceSpy = vi.spyOn(CSSStyleSheet.prototype, "replaceSync");
await import("../src/style");
const styleEl = document.head.querySelector("style");
expect(replaceSpy).not.toHaveBeenCalled();
expect(styleEl).not.toBeNull();
expect(styleEl?.textContent).not.toBe("");
});
});

View File

@ -1,86 +0,0 @@
import { describe, it, expect } from "vitest";
import {
isOn,
omitOn,
isValidArrayIndex,
isSameSet,
isPlainObject,
} from "../src/utils";
describe("utils", () => {
describe("isOn", () => {
it("recognizes vue-style event props", () => {
expect(isOn("onClick")).toBe(true);
expect(isOn("onNative:click")).toBe(true);
expect(isOn("onZr:mouseover")).toBe(true);
expect(isOn("onUpdate:modelValue")).toBe(true);
expect(isOn("on")).toBe(false);
});
it("ignores non-event keys", () => {
expect(isOn("onclick")).toBe(false);
expect(isOn("onupdate:modelValue")).toBe(false);
expect(isOn("foo")).toBe(false);
});
});
describe("omitOn", () => {
it("returns attrs without event handlers", () => {
const attrs = {
id: "chart",
onClick: () => void 0,
onNative: () => void 0,
class: "foo",
};
const result = omitOn(attrs);
expect(result).toEqual({ id: "chart", class: "foo" });
expect("onClick" in result).toBe(false);
expect(attrs).toHaveProperty("onClick");
expect(result).not.toBe(attrs);
});
});
describe("isValidArrayIndex", () => {
it("accepts non-negative integer strings", () => {
expect(isValidArrayIndex("0")).toBe(true);
expect(isValidArrayIndex("42")).toBe(true);
expect(isValidArrayIndex("4294967294")).toBe(true);
expect(isValidArrayIndex(" 1")).toBe(false);
});
it("rejects invalid inputs", () => {
expect(isValidArrayIndex("-1")).toBe(false);
expect(isValidArrayIndex("3.14")).toBe(false);
expect(isValidArrayIndex("1e3")).toBe(false);
expect(isValidArrayIndex("foo")).toBe(false);
});
});
describe("isSameSet", () => {
it("detects identical sets regardless of order", () => {
expect(isSameSet([1, 2, 2, 3], [3, 2, 1])).toBe(true);
expect(isSameSet([1, 2, 2, 3], [3, 4, 1])).toBe(false);
});
it("detects differing sets", () => {
expect(isSameSet([1, 2], [1, 2, 3])).toBe(false);
expect(isSameSet([1, 2], [1, 3])).toBe(false);
});
});
describe("isPlainObject", () => {
it("accepts plain objects", () => {
expect(isPlainObject({ foo: "bar" })).toBe(true);
expect(isPlainObject(() => ({ foo: "bar" }))).toBe(false);
});
it("rejects arrays and primitives", () => {
expect(isPlainObject([])).toBe(false);
expect(isPlainObject(null)).toBe(false);
expect(isPlainObject("foo")).toBe(false);
});
});
});

View File

@ -1,166 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
declare global {
interface HTMLElement {
__dispose?: (() => void) | null;
}
}
type LoadOptions = { suffix?: string };
const loadModule = (() => {
let counter = 0;
return async (mode: "stub" | "native", options?: LoadOptions) => {
const suffix = options?.suffix ?? `${mode}-${++counter}`;
return import(/* @vite-ignore */ `../src/wc?${suffix}`);
};
})();
describe("register", () => {
describe("with stubbed customElements", () => {
class CustomElementRegistryStub {
private readonly registry = new Map<string, CustomElementConstructor>();
define(name: string, ctor: CustomElementConstructor): void {
if (this.registry.has(name)) {
throw new DOMException("already defined", "NotSupportedError");
}
this.registry.set(name, ctor);
}
get(name: string): CustomElementConstructor | undefined {
return this.registry.get(name);
}
}
let registry: CustomElementRegistryStub;
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
registry = new CustomElementRegistryStub();
vi.stubGlobal(
"customElements",
registry as unknown as CustomElementRegistry,
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("returns false when custom elements are unavailable", async () => {
vi.unstubAllGlobals();
vi.stubGlobal(
"customElements",
undefined as unknown as CustomElementRegistry,
);
const { register } = await loadModule("stub");
expect(register()).toBe(false);
expect(register()).toBe(false);
});
it("returns false when browser APIs are disabled", async () => {
vi.resetModules();
// Simulate missing browser API by providing a registry without `get`
vi.stubGlobal("customElements", {
define() {},
} as unknown as CustomElementRegistry);
const { register } = await loadModule("stub", { suffix: "no-get" });
expect(register()).toBe(false);
expect(register()).toBe(false);
});
it("registers the custom element once", async () => {
const defineSpy = vi.spyOn(registry, "define");
const { register, TAG_NAME } = await loadModule("stub");
expect(register()).toBe(true);
expect(defineSpy).toHaveBeenCalledTimes(1);
expect(registry.get(TAG_NAME)).toBeTypeOf("function");
defineSpy.mockClear();
expect(register()).toBe(true);
expect(defineSpy).not.toHaveBeenCalled();
});
it("handles definition failures gracefully", async () => {
const defineSpy = vi.spyOn(registry, "define").mockImplementation(() => {
throw new Error("boom");
});
const { register, TAG_NAME } = await loadModule("stub");
expect(register()).toBe(false);
expect(register()).toBe(false);
expect(defineSpy).toHaveBeenCalledTimes(1);
expect(registry.get(TAG_NAME)).toBeUndefined();
});
it("skips redefinition when element already registered", async () => {
const existing = class extends HTMLElement {};
const { register, TAG_NAME } = await loadModule("stub");
registry.define(TAG_NAME, existing);
const defineSpy = vi.spyOn(registry, "define");
expect(register()).toBe(true);
expect(defineSpy).not.toHaveBeenCalled();
expect(registry.get(TAG_NAME)).toBe(existing);
});
it("exposes a constructor with disconnect hook", async () => {
const { register, TAG_NAME } = await loadModule("stub");
expect(register()).toBe(true);
const ctor = registry.get(TAG_NAME);
expect(typeof ctor).toBe("function");
expect("disconnectedCallback" in (ctor?.prototype ?? {})).toBe(true);
});
});
describe("with native customElements", () => {
let original: CustomElementConstructor | undefined;
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
original = customElements.get("x-vue-echarts");
document.body.innerHTML = "";
});
afterEach(() => {
document.body.innerHTML = "";
if (original) {
customElements.define("x-vue-echarts", original);
}
});
it("disposes chart when element is removed from DOM", async () => {
const { register, TAG_NAME } = await loadModule("native");
expect(register()).toBe(true);
const element = document.createElement(TAG_NAME) as HTMLElement & {
__dispose: (() => void) | null;
};
const dispose = vi.fn();
element.__dispose = dispose;
document.body.appendChild(element);
document.body.removeChild(element);
await Promise.resolve();
expect(dispose).toHaveBeenCalledTimes(1);
expect(element.__dispose).toBeNull();
});
});
});

View File

@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals", "vite/client"],
"noEmit": true
},
"include": ["tests/**/*.ts", "tests/types/**/*.d.ts"]
}

View File

@ -1,30 +0,0 @@
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";
export default mergeConfig(
viteConfig,
defineConfig({
root: ".",
test: {
globals: true,
setupFiles: ["./tests/setup.ts"],
include: ["tests/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "lcov", "html"],
include: ["src/**/*.{ts,tsx,js,jsx,vue}"],
reportsDirectory: "coverage/browser",
},
browser: {
enabled: true,
provider: "playwright",
headless: true,
instances: [
{
browser: "chromium",
},
],
},
},
}),
);