mirror of
https://github.com/ecomfe/vue-echarts.git
synced 2025-11-06 13:09:50 +08:00
Compare commits
21 Commits
v8.0.0-bet
...
8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 36c3cb97a9 | |||
| 55c68b48b7 | |||
| c232e71c47 | |||
| e568005bb2 | |||
| 8ed975e09b | |||
| 570a26c262 | |||
| df640ebce6 | |||
| 30e7934aab | |||
| 6155bbb409 | |||
| fa42af0723 | |||
| 8b7ef5e6e1 | |||
| 522dd7cc5c | |||
| 077bd3ec40 | |||
| 473fed37a2 | |||
| 7ae6892fe6 | |||
| 71c106ae29 | |||
| 381489da2f | |||
| 2fb0dc2233 | |||
| b6c84aab7e | |||
| c6a1228c9d | |||
| 9067505a3a |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -1 +0,0 @@
|
||||
* @ecomfe/vue-echarts
|
||||
6
.github/ISSUE_TEMPLATE/bug-report.en-US.yml
vendored
6
.github/ISSUE_TEMPLATE/bug-report.en-US.yml
vendored
@ -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"
|
||||
|
||||
@ -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> 标签"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
name: "✨ 新功能建议"
|
||||
description: 给 Vue ECharts 提交新功能建议
|
||||
description: 给 Vue-ECharts 提交新功能建议
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@ -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
2
.gitignore
vendored
@ -1,8 +1,6 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
/coverage
|
||||
/tests/__screenshots__
|
||||
|
||||
|
||||
# local env files
|
||||
|
||||
21
AGENTS.md
21
AGENTS.md
@ -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 library’s 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`.
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@ -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.
|
||||
|
||||
43
README.md
43
README.md
@ -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/).
|
||||
|
||||
@ -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/).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 |
@ -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 |
@ -3,7 +3,6 @@
|
||||
"include": ["./**/*", "./**/*.vue"],
|
||||
"compilerOptions": {
|
||||
"types": ["vite/client"],
|
||||
"allowJs": true,
|
||||
"noUncheckedIndexedAccess": false
|
||||
"allowJs": true
|
||||
}
|
||||
}
|
||||
|
||||
41
package.json
41
package.json
@ -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
2987
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -42,7 +42,7 @@ README_FILES.forEach((file) => {
|
||||
writeFileSync(
|
||||
file,
|
||||
commentMark(content, {
|
||||
scripts: getCodeBlock(getScripts()),
|
||||
vue3Scripts: getCodeBlock(getScripts()),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
116
src/ECharts.ts
116
src/ECharts.ts
@ -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,
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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;}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
41
src/wc.ts
41
src/wc.ts
@ -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);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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)),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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 = "";
|
||||
}
|
||||
@ -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();
|
||||
@ -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);
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { cleanup, render } from "vitest-browser-vue/pure";
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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("");
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
166
tests/wc.test.ts
166
tests/wc.test.ts
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals", "vite/client"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["tests/**/*.ts", "tests/types/**/*.d.ts"]
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user