From 01857d234a417357e7db9acee0e57809ae0c06cc Mon Sep 17 00:00:00 2001 From: Bartosz-Do Date: Mon, 28 Sep 2026 12:04:37 +0200 Subject: [PATCH] Fix `Sparkline` empty render, stale tween attributes and one-value line (#3227) --- .changeset/fix-sparkline-empty-render.md | 6 +++ core/js/src/sparkline.ts | 21 +++++--- core/js/tests/unit/sparkline.spec.ts | 68 ++++++++++++++++++++++-- docs/content/ui/components/sparkline.mdx | 2 +- 4 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 .changeset/fix-sparkline-empty-render.md diff --git a/.changeset/fix-sparkline-empty-render.md b/.changeset/fix-sparkline-empty-render.md new file mode 100644 index 000000000..df2c9aa61 --- /dev/null +++ b/.changeset/fix-sparkline-empty-render.md @@ -0,0 +1,6 @@ +--- +"@tabler/core": patch +"@tabler/docs": patch +--- + +Fixed `Sparkline` wiping author content when empty or disposed, keeping stale tween attributes and skipping single-value lines. diff --git a/core/js/src/sparkline.ts b/core/js/src/sparkline.ts index 43151bb86..752f25368 100644 --- a/core/js/src/sparkline.ts +++ b/core/js/src/sparkline.ts @@ -262,7 +262,9 @@ class Sparkline extends BaseComponent { this._element.querySelector(`.${CLASS_NAME_LABEL}`)?.remove() if (this._config.values.length === 0) { - this._element.innerHTML = '' + cancelAnimationFrame(this._frame) + previous?.remove() + EventHandler.trigger(this._element, EVENT_RENDERED) return } @@ -308,7 +310,8 @@ class Sparkline extends BaseComponent { dispose(): void { cancelAnimationFrame(this._frame) - this._element.innerHTML = '' + this._element.querySelector('svg')?.remove() + this._element.querySelector(`.${CLASS_NAME_LABEL}`)?.remove() super.dispose() } @@ -365,6 +368,12 @@ class Sparkline extends BaseComponent { const pairs = Array.from(to.children).map((target, i) => { const source = from.children[i] const tweens: [string, string, string][] = [] + for (const { name } of Array.from(source.attributes)) { + if (!target.hasAttribute(name)) { + source.removeAttribute(name) + } + } + for (const { name, value } of Array.from(target.attributes)) { const start = source.getAttribute(name) if (start === null || start === value) { @@ -431,8 +440,8 @@ class Sparkline extends BaseComponent { const { min, span } = rangeOf(cfg.values, cfg.min, cfg.max, ...(cfg.threshold === null ? [] : [cfg.threshold])) const yOf = (value: number): number => cfg.pad + (1 - (value - min) / span) * (cfg.height - cfg.pad * 2) - const ys = cfg.values.map(yOf) - const step = ys.length > 1 ? cfg.width / (ys.length - 1) : cfg.width + const ys = cfg.values.length === 1 ? [yOf(cfg.values[0]), yOf(cfg.values[0])] : cfg.values.map(yOf) + const step = cfg.width / (ys.length - 1) if (cfg.threshold !== null) { this._hairline(svg, yOf(cfg.threshold), 'threshold', true) @@ -463,7 +472,7 @@ class Sparkline extends BaseComponent { if (spotIndex >= 0) { const spot = svgEl('circle') setAttr(spot, { - cx: spotIndex * step, + cx: cfg.values.length === 1 ? cfg.width / 2 : spotIndex * step, cy: ys[spotIndex], r: getCssNumber(this._element, 'spot-size', 2), fill: cssVar('spot'), @@ -560,7 +569,7 @@ class Sparkline extends BaseComponent { const cfg = this._config const svg = this._createSvg() - const strokeWidth = getCssNumber(this._element, 'stroke-width', 3) + const strokeWidth = getCssNumber(this._element, 'stroke-width', 2) const radius = Math.max(0, Math.min(cfg.width, cfg.height) / 2 - strokeWidth / 2) const cx = cfg.width / 2 const cy = cfg.height / 2 diff --git a/core/js/tests/unit/sparkline.spec.ts b/core/js/tests/unit/sparkline.spec.ts index 386eede55..992b6c7d7 100644 --- a/core/js/tests/unit/sparkline.spec.ts +++ b/core/js/tests/unit/sparkline.spec.ts @@ -156,9 +156,42 @@ describe('Sparkline', () => { it('should render nothing without values', () => { fixtureEl.innerHTML = '' + const spy = vi.fn() + el().addEventListener('rendered.bs.sparkline', spy) new Sparkline(el()) expect(el().innerHTML).toBe('') + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should keep author content when rendering without values', () => { + fixtureEl.innerHTML = 'n/a' + + const sparkline = new Sparkline(el(), { values: [1, 2], label: 'x' }) + sparkline.update([]) + + expect(el().querySelector('svg')).toBeNull() + expect(el().querySelector('.sparkline-label')).toBeNull() + expect(el().querySelector('.note')).not.toBeNull() + }) + + it('should draw a flat line for a single value', () => { + fixtureEl.innerHTML = '' + + new Sparkline(el()) + + const y = el().querySelector('polyline')!.getAttribute('points')!.split(' ')[0].split(',')[1] + expect(el().querySelector('polyline')!.getAttribute('points')).toBe(`0,${y} 80,${y}`) + expect(el().querySelector('path')!.getAttribute('d')).toBe(`M 0 ${y} L 80 ${y} L 80 24 L 0 24 Z`) + expect(el().querySelector('circle')!.getAttribute('cx')).toBe('40') + }) + + it('should fall back to a stroke width of 2 without computed styles', () => { + const element = document.createElement('span') + + new Sparkline(element, { type: 'circle', values: [50] }) + + expect(element.querySelector('circle')!.getAttribute('stroke-width')).toBe('2') }) it('should accept values from the config object', () => { @@ -209,6 +242,35 @@ describe('Sparkline', () => { expect(heights).toEqual(['24', '12']) }) + it('should drop attributes the new element does not have', async () => { + fixtureEl.innerHTML = '' + + const sparkline = new Sparkline(el()) + const line = el().querySelector('line')! + expect(line.getAttribute('stroke-dasharray')).toBe('3 2') + + el().removeAttribute('data-bs-threshold') + sparkline.update([1, -2]) + await new Promise((resolve) => setTimeout(resolve, 200)) + + expect(el().querySelector('line')).toBe(line) + expect(line.hasAttribute('stroke-dasharray')).toBe(false) + }) + + it('should stop a running tween when the values are cleared', async () => { + fixtureEl.innerHTML = '' + + const sparkline = new Sparkline(el(), { type: 'bar', values: [1, 2], animation: 1000 }) + const rect = el().querySelector('rect')! + sparkline.update([2, 1]) + sparkline.update([]) + const height = rect.getAttribute('height') + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(rect.isConnected).toBe(false) + expect(rect.getAttribute('height')).toBe(height) + }) + it('should replace the svg when the shape changes or animation is off', () => { fixtureEl.innerHTML = '' @@ -221,13 +283,13 @@ describe('Sparkline', () => { }) describe('dispose', () => { - it('should empty the element and drop the instance', () => { - fixtureEl.innerHTML = '' + it('should remove the chart and drop the instance', () => { + fixtureEl.innerHTML = 'n/a' const sparkline = new Sparkline(el()) sparkline.dispose() - expect(el().innerHTML).toBe('') + expect(el().innerHTML).toBe('n/a') expect(Sparkline.getInstance(el())).toBeNull() }) }) diff --git a/docs/content/ui/components/sparkline.mdx b/docs/content/ui/components/sparkline.mdx index af6177207..e5d96f08c 100644 --- a/docs/content/ui/components/sparkline.mdx +++ b/docs/content/ui/components/sparkline.mdx @@ -207,7 +207,7 @@ document.getElementById('orders').addEventListener('updated.bs.sparkline', () => | --- | --- | | `update(values)` | Redraws with new values, given as an array, a number or a string. Fires `updated.bs.sparkline`. | | `render()` | Reads the attributes again and redraws. Fires `rendered.bs.sparkline`. | -| `dispose()` | Removes the SVG and the component from the element. | +| `dispose()` | Removes the SVG, the label and the component from the element. Other content in the element stays. | | `getInstance(element)` | Static. Returns the component for the element, or `null`. | | `getOrCreateInstance(element, config)` | Static. Returns the component for the element and creates it when needed. |