Fix Sparkline empty render, stale tween attributes and one-value line (#3227)

This commit is contained in:
Bartosz-Do authored and GitHub committed 2026-09-28 12:04:37 +02:00
1 parent 6199b80b28
commit 01857d234a
4 files changed
+87 -10

No files matched your search

+6
View File
@@ -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.
+15 -6
View File
@@ -262,7 +262,9 @@ class Sparkline extends BaseComponent {
this._element.querySelector(`.${CLASS_NAME_LABEL}`)?.remove() this._element.querySelector(`.${CLASS_NAME_LABEL}`)?.remove()
if (this._config.values.length === 0) { if (this._config.values.length === 0) {
this._element.innerHTML = '' cancelAnimationFrame(this._frame)
previous?.remove()
EventHandler.trigger(this._element, EVENT_RENDERED)
return return
} }
@@ -308,7 +310,8 @@ class Sparkline extends BaseComponent {
dispose(): void { dispose(): void {
cancelAnimationFrame(this._frame) cancelAnimationFrame(this._frame)
this._element.innerHTML = '' this._element.querySelector('svg')?.remove()
this._element.querySelector(`.${CLASS_NAME_LABEL}`)?.remove()
super.dispose() super.dispose()
} }
@@ -365,6 +368,12 @@ class Sparkline extends BaseComponent {
const pairs = Array.from(to.children).map((target, i) => { const pairs = Array.from(to.children).map((target, i) => {
const source = from.children[i] const source = from.children[i]
const tweens: [string, string, string][] = [] 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)) { for (const { name, value } of Array.from(target.attributes)) {
const start = source.getAttribute(name) const start = source.getAttribute(name)
if (start === null || start === value) { 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 { 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 yOf = (value: number): number => cfg.pad + (1 - (value - min) / span) * (cfg.height - cfg.pad * 2)
const ys = cfg.values.map(yOf) const ys = cfg.values.length === 1 ? [yOf(cfg.values[0]), yOf(cfg.values[0])] : cfg.values.map(yOf)
const step = ys.length > 1 ? cfg.width / (ys.length - 1) : cfg.width const step = cfg.width / (ys.length - 1)
if (cfg.threshold !== null) { if (cfg.threshold !== null) {
this._hairline(svg, yOf(cfg.threshold), 'threshold', true) this._hairline(svg, yOf(cfg.threshold), 'threshold', true)
@@ -463,7 +472,7 @@ class Sparkline extends BaseComponent {
if (spotIndex >= 0) { if (spotIndex >= 0) {
const spot = svgEl('circle') const spot = svgEl('circle')
setAttr(spot, { setAttr(spot, {
cx: spotIndex * step, cx: cfg.values.length === 1 ? cfg.width / 2 : spotIndex * step,
cy: ys[spotIndex], cy: ys[spotIndex],
r: getCssNumber(this._element, 'spot-size', 2), r: getCssNumber(this._element, 'spot-size', 2),
fill: cssVar('spot'), fill: cssVar('spot'),
@@ -560,7 +569,7 @@ class Sparkline extends BaseComponent {
const cfg = this._config const cfg = this._config
const svg = this._createSvg() 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 radius = Math.max(0, Math.min(cfg.width, cfg.height) / 2 - strokeWidth / 2)
const cx = cfg.width / 2 const cx = cfg.width / 2
const cy = cfg.height / 2 const cy = cfg.height / 2
+65 -3
View File
@@ -156,9 +156,42 @@ describe('Sparkline', () => {
it('should render nothing without values', () => { it('should render nothing without values', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline"></span>' fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline"></span>'
const spy = vi.fn()
el().addEventListener('rendered.bs.sparkline', spy)
new Sparkline(el()) new Sparkline(el())
expect(el().innerHTML).toBe('') expect(el().innerHTML).toBe('')
expect(spy).toHaveBeenCalledTimes(1)
})
it('should keep author content when rendering without values', () => {
fixtureEl.innerHTML = '<span class="sparkline"><b class="note">n/a</b></span>'
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 = '<span class="sparkline" data-bs-fill="auto" data-bs-spot="last" data-bs-values="5"></span>'
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', () => { it('should accept values from the config object', () => {
@@ -209,6 +242,35 @@ describe('Sparkline', () => {
expect(heights).toEqual(['24', '12']) expect(heights).toEqual(['24', '12'])
}) })
it('should drop attributes the new element does not have', async () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-type="bar" data-bs-threshold="1.5" data-bs-animation="50" data-bs-values="1,2"></span>'
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 = '<span class="sparkline"></span>'
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', () => { it('should replace the svg when the shape changes or animation is off', () => {
fixtureEl.innerHTML = '<span class="sparkline"></span>' fixtureEl.innerHTML = '<span class="sparkline"></span>'
@@ -221,13 +283,13 @@ describe('Sparkline', () => {
}) })
describe('dispose', () => { describe('dispose', () => {
it('should empty the element and drop the instance', () => { it('should remove the chart and drop the instance', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-values="1,2"></span>' fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-label="auto" data-bs-values="1,2"><b class="note">n/a</b></span>'
const sparkline = new Sparkline(el()) const sparkline = new Sparkline(el())
sparkline.dispose() sparkline.dispose()
expect(el().innerHTML).toBe('') expect(el().innerHTML).toBe('<b class="note">n/a</b>')
expect(Sparkline.getInstance(el())).toBeNull() expect(Sparkline.getInstance(el())).toBeNull()
}) })
}) })
+1 -1
View File
@@ -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`. | | `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`. | | `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`. | | `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. | | `getOrCreateInstance(element, config)` | Static. Returns the component for the element and creates it when needed. |