Add sparkline component (#3051)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-14 20:17:44 +02:00
1 parent 2129b0b881
commit 5d5be36332
19 files changed
+1633 -20

No files matched your search

+21 -3
View File
@@ -102,6 +102,22 @@ Prose and example markup are only as accurate as their source. Before writing ex
- **No trailing slash on `url`.** The menu compares it against the page url, which never has one, so a trailing slash silently breaks the active-item highlight for that page.
- The `title` and `url` must match the new page's frontmatter `title` and its file path exactly, or the sidebar entry will point at the wrong place.
## 7a. Linking a new page from the demo page
Every component with a page in `preview/pages/**` shows a "Documentation" button in its page header, rendered by `DocsLink`. A new docs page is easy to forget here, and the demo page then looks undocumented even though the docs exist.
- After creating `docs/content/ui/components/<name>.mdx` (or a plugin page), open `preview/pages/<name>.astro` and add, as a direct child of `<DefaultLayout>`:
```astro
import DocsLink from '@ui/DocsLink.astro'
…
<DocsLink slot="page-header-actions" path="/ui/components/<name>" />
```
- `path` is the docs path without the extension, the same string as the `url` in `docs.json`.
- `pnpm run check:docs-links` resolves every `DocsLink` against `docs/content/**` and fails on a typo, so run it after adding one.
- When there is no demo page yet, say so — the `demo-pages` skill covers building one.
## 8. Workflow for each request
1. Identify target file(s) in `docs/content/**` (or determine none exist yet — see section 7).
@@ -110,9 +126,10 @@ Prose and example markup are only as accurate as their source. Before writing ex
4. Apply the schema from section 4.
5. Write/update the page directly in file(s).
6. If the page is new, add its entry to `shared/data/docs.json` per section 7.
7. Keep only required frontmatter by default.
8. Ensure prose is in simple English.
9. Verify heading hierarchy (`##` then `###`) and snippet validity.
7. If the page is new, link it from the matching demo page in `preview/pages/<name>.astro` per section 7a.
8. Keep only required frontmatter by default.
9. Ensure prose is in simple English.
10. Verify heading hierarchy (`##` then `###`) and snippet validity.
## 8a. Checking a page on the published site
@@ -142,4 +159,5 @@ Check a page's live url on `docs-dev.tabler.io`. Production still serves the pre
- [ ] Examples use the `Example` component pattern where applicable.
- [ ] Accessibility section exists for interactive UI docs.
- [ ] New pages have a matching entry in `shared/data/docs.json` (section 7); edits to existing pages don't touch it.
- [ ] New pages are linked from their demo page with `DocsLink` (section 7a), and `pnpm run check:docs-links` passes.
- [ ] No mention of changeset reminders unless user asks.
+7
View File
@@ -0,0 +1,7 @@
---
"@tabler/core": minor
"@tabler/preview": minor
"@tabler/docs": minor
---
Added `Sparkline` component: inline SVG line, bar and circle charts from `data-bs-values`, with size classes and signed bars.
+611
View File
@@ -0,0 +1,611 @@
/**
* --------------------------------------------------------------------------
* Tabler sparkline.ts
* Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine'
import type { ComponentConfig as BaseConfig, ElementSelector } from './bootstrap/types'
type SparklineType = 'line' | 'bar' | 'circle' | 'tristate'
type SparklineFill = 'none' | 'auto'
type SparklineSpot = 'none' | 'min' | 'max' | 'last'
type SparklineValuesInput = number[] | number | string
type ComponentConfig = {
type: SparklineType
values: number[]
width: number
height: number
min: number | null
max: number | null
fill: SparklineFill
spot: SparklineSpot
pad: number
barGap: number
barRadius: number
label: string | number | boolean | null
threshold: number | null
animation: number
}
type ComponentConfigInput = Partial<Omit<ComponentConfig, 'values'>> & {
values?: SparklineValuesInput
}
/**
* Constants
*/
const NAME = 'sparkline'
const DATA_KEY = `bs.${NAME}`
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_RENDERED = `rendered${EVENT_KEY}`
const EVENT_UPDATED = `updated${EVENT_KEY}`
const CLASS_NAME_SVG = 'sparkline-svg'
const CLASS_NAME_LABEL = 'sparkline-label'
const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]`
// Custom properties are declared on `.sparkline` in scss/ui/_sparkline.scss and
// get the `--tblr-` prefix at build time.
const CSS_VAR_PREFIX = '--tblr-sparkline'
const SVG_NS = 'http://www.w3.org/2000/svg'
const Default: ComponentConfig = {
type: 'line',
values: [],
width: 80,
height: 24,
min: null,
max: null,
fill: 'none',
spot: 'none',
pad: 2,
barGap: 2,
barRadius: 2,
label: null,
threshold: null,
animation: 300,
}
const DefaultType: Record<keyof ComponentConfig, string> = {
type: 'string',
values: 'array',
width: 'number',
height: 'number',
min: '(number|null)',
max: '(number|null)',
fill: 'string',
spot: 'string',
pad: 'number',
barGap: 'number',
barRadius: 'number',
label: '(string|number|boolean|null)',
threshold: '(number|null)',
animation: 'number',
}
/**
* Helpers
*/
const svgEl = <K extends keyof SVGElementTagNameMap>(tag: K): SVGElementTagNameMap[K] => document.createElementNS(SVG_NS, tag)
const setAttr = (el: Element, attrs: Record<string, string | number | null | undefined>): void => {
for (const [key, value] of Object.entries(attrs)) {
if (value !== null && value !== undefined) {
el.setAttribute(key, String(value))
}
}
}
const cssVar = (name: string): string => `var(${CSS_VAR_PREFIX}-${name})`
const clampSpan = (min: number, max: number): number => {
const span = max - min
return span === 0 ? 1 : span
}
// Accepts "3,4,2" as well as a JSON array literal "[3,4,2]".
const parseNumberList = (input: unknown): number[] => {
const raw = String(input ?? '').trim()
if (!raw) {
return []
}
if (raw.startsWith('[')) {
try {
const arr = JSON.parse(raw)
return Array.isArray(arr) ? arr.map(Number).filter(Number.isFinite) : []
} catch {
return []
}
}
return raw
.split(',')
.map((value) => Number(value.trim()))
.filter(Number.isFinite)
}
const toValues = (input: unknown): number[] => {
if (Array.isArray(input)) {
return input.map(Number).filter(Number.isFinite)
}
if (typeof input === 'number') {
return Number.isFinite(input) ? [input] : []
}
return parseNumberList(input)
}
// The value range a chart is drawn in: forced by `min` / `max`, otherwise the
// data itself, widened to include the threshold (and zero for bars) so those
// lines are always inside the chart.
const rangeOf = (values: number[], minForced: number | null, maxForced: number | null, ...include: number[]): { min: number; max: number; span: number } => {
const min = minForced ?? Math.min(...values, ...include)
const max = maxForced ?? Math.max(...values, ...include)
return { min, max, span: clampSpan(min, max) }
}
const NUMBER_RE = /-?\d*\.?\d+(?:e[-+]?\d+)?/g
// Interpolates the numbers inside two attribute values with the same shape:
// "0,20 40,10" → "0,10 40,20", or a path `d`. Falls back to the target when
// the shapes differ.
const lerpAttr = (from: string, to: string, t: number): string => {
const a = from.match(NUMBER_RE)
const b = to.match(NUMBER_RE)
if (!a || !b || a.length !== b.length || from.replace(NUMBER_RE, '#') !== to.replace(NUMBER_RE, '#')) {
return to
}
let i = 0
return to.replace(NUMBER_RE, () => {
const value = Number(a[i]) + (Number(b[i]) - Number(a[i])) * t
i++
return String(Math.round(value * 1000) / 1000)
})
}
const easeOut = (t: number): number => 1 - (1 - t) ** 3
const findIndexByMode = (values: number[], mode: SparklineSpot): number => {
if (values.length === 0 || mode === 'none') {
return -1
}
if (mode === 'last') {
return values.length - 1
}
let idx = 0
for (let i = 1; i < values.length; i++) {
if (mode === 'min' ? values[i] < values[idx] : values[i] > values[idx]) {
idx = i
}
}
return idx
}
const getCssNumber = (element: Element, name: string, fallback: number): number => {
const raw = getComputedStyle(element).getPropertyValue(`${CSS_VAR_PREFIX}-${name}`).trim()
const value = Number.parseFloat(raw)
return Number.isFinite(value) ? value : fallback
}
/**
* Class definition
*
* Tiny inline SVG chart (line, bar or circle) rendered from data attributes:
*
* <span class="sparkline" data-bs-toggle="sparkline" data-bs-type="line" data-bs-values="3,4,2,6,5,8,7"></span>
*
* Colours and stroke widths come from the `--tblr-sparkline-*` custom
* properties, so a text colour utility on the element themes the chart.
*/
class Sparkline extends BaseComponent {
declare _element: HTMLElement
declare _config: ComponentConfig
_userConfig: ComponentConfigInput = {}
_frame = 0
constructor(element: ElementSelector, config?: ComponentConfigInput) {
super(element, config)
if (!this._element) {
return
}
// Kept so that render() can re-read the data attributes on top of it.
this._userConfig = { ...config }
this.render()
}
// Getters
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): Record<keyof ComponentConfig, string> {
return DefaultType
}
static get NAME(): string {
return NAME
}
// Public
update(values: SparklineValuesInput): void {
const serialized = Array.isArray(values) ? values.join(',') : String(values)
const attribute = this._element.hasAttribute('data-tblr-values') ? 'data-tblr-values' : 'data-bs-values'
this._element.setAttribute(attribute, serialized)
// The attribute is now the source of truth for the values.
delete this._userConfig.values
this.render()
EventHandler.trigger(this._element, EVENT_UPDATED)
}
render(): void {
this._config = this._getConfig(this._userConfig) as ComponentConfig
const previous = this._element.querySelector<SVGSVGElement>('svg')
this._element.querySelector(`.${CLASS_NAME_LABEL}`)?.remove()
if (this._config.values.length === 0) {
this._element.innerHTML = ''
return
}
let svg: SVGSVGElement
switch (this._config.type) {
case 'bar':
svg = this._renderBars()
break
case 'tristate':
svg = this._renderTristate()
break
case 'circle':
svg = this._renderCircle()
break
default:
svg = this._renderLine()
break
}
// On an update with the same shape (type and number of values) the old
// SVG stays in place and its attributes are tweened to the new ones.
if (previous && this._canAnimate(previous, svg)) {
this._animate(previous, svg)
} else {
cancelAnimationFrame(this._frame)
previous?.remove()
this._element.append(svg)
}
// A text label centered over the chart, mainly for the circle type. Plain
// HTML rather than SVG text, so it keeps the page font and is not
// stretched with the viewBox.
const text = this._labelText()
if (text !== '') {
const label = document.createElement('span')
label.className = CLASS_NAME_LABEL
label.textContent = text
this._element.append(label)
}
EventHandler.trigger(this._element, EVENT_RENDERED)
}
dispose(): void {
cancelAnimationFrame(this._frame)
this._element.innerHTML = ''
super.dispose()
}
// Private
_configAfterMerge(config: BaseConfig): BaseConfig {
config.values = toValues(config.values)
return config
}
// `auto` (or `true`) derives the label from the data: the share of `max`
// for a circle, the last value otherwise. Anything else is printed as is.
_labelText(): string {
const { label, values, type } = this._config
if (label === null || label === false || label === '') {
return ''
}
if (label !== 'auto' && label !== true) {
return String(label)
}
if (type === 'circle') {
return `${Math.round(this._circleRatio() * 100)}%`
}
return String(values[values.length - 1])
}
// A single value is a share of `max` (default 100); a second value may be
// passed as the maximum instead: data-bs-values="72,100".
_circleRatio(): number {
const cfg = this._config
const value = cfg.values[0] ?? 0
const max = cfg.max ?? (cfg.values.length > 1 ? cfg.values[1] : 100)
const min = cfg.min ?? 0
return Math.max(0, Math.min(1, (value - min) / clampSpan(min, max)))
}
_canAnimate(from: SVGSVGElement, to: SVGSVGElement): boolean {
if (this._config.animation <= 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return false
}
if (from.getAttribute('viewBox') !== to.getAttribute('viewBox') || from.children.length !== to.children.length) {
return false
}
return Array.from(from.children).every((child, i) => child.tagName === to.children[i].tagName)
}
_animate(from: SVGSVGElement, to: SVGSVGElement): void {
cancelAnimationFrame(this._frame)
const pairs = Array.from(to.children).map((target, i) => {
const source = from.children[i]
const tweens: [string, string, string][] = []
for (const { name, value } of Array.from(target.attributes)) {
const start = source.getAttribute(name)
if (start === null || start === value) {
source.setAttribute(name, value)
} else {
tweens.push([name, start, value])
}
}
return { source, tweens }
})
const duration = this._config.animation
const started = performance.now()
const step = (now: number): void => {
const t = easeOut(Math.min(1, (now - started) / duration))
for (const { source, tweens } of pairs) {
for (const [name, start, end] of tweens) {
source.setAttribute(name, t >= 1 ? end : lerpAttr(start, end, t))
}
}
if (t < 1) {
this._frame = requestAnimationFrame(step)
}
}
this._frame = requestAnimationFrame(step)
}
// A 1px horizontal guide: solid for the zero line, dashed for the threshold.
_hairline(svg: SVGSVGElement, y: number, color: string, dashed = false): void {
const line = svgEl('line')
setAttr(line, {
'x1': 0,
'x2': this._config.width,
'y1': y,
'y2': y,
'stroke': cssVar(color),
'stroke-width': 1,
'stroke-dasharray': dashed ? '3 2' : null,
'vector-effect': 'non-scaling-stroke',
})
svg.append(line)
}
_createSvg(): SVGSVGElement {
const { width, height } = this._config
const svg = svgEl('svg')
setAttr(svg, {
'viewBox': `0 0 ${width} ${height}`,
'preserveAspectRatio': 'none',
'width': width,
'height': height,
'class': CLASS_NAME_SVG,
'aria-hidden': 'true',
})
return svg
}
_renderLine(): SVGSVGElement {
const cfg = this._config
const svg = this._createSvg()
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
if (cfg.threshold !== null) {
this._hairline(svg, yOf(cfg.threshold), 'threshold', true)
}
if (cfg.fill === 'auto') {
const area = svgEl('path')
setAttr(area, {
d: this._areaPath(ys, step, cfg.height),
fill: cssVar('fill'),
stroke: 'none',
})
svg.append(area)
}
const line = svgEl('polyline')
setAttr(line, {
'points': ys.map((y, i) => `${i * step},${y}`).join(' '),
'fill': 'none',
'stroke': cssVar('stroke'),
'stroke-width': cssVar('stroke-width'),
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
})
svg.append(line)
const spotIndex = findIndexByMode(cfg.values, cfg.spot)
if (spotIndex >= 0) {
const spot = svgEl('circle')
setAttr(spot, {
cx: spotIndex * step,
cy: ys[spotIndex],
r: getCssNumber(this._element, 'spot-size', 2),
fill: cssVar('spot'),
})
svg.append(spot)
}
return svg
}
_areaPath(ys: number[], step: number, height: number): string {
const top = ys.map((y, i) => `${i === 0 ? 'M' : 'L'} ${i * step} ${y}`).join(' ')
const lastX = (ys.length - 1) * step
return `${top} L ${lastX} ${height} L 0 ${height} Z`
}
_renderBars(): SVGSVGElement {
const cfg = this._config
const svg = this._createSvg()
const { min, span } = rangeOf(cfg.values, cfg.min, cfg.max, 0, ...(cfg.threshold === null ? [] : [cfg.threshold]))
const yOf = (value: number): number => cfg.height - ((value - min) / span) * cfg.height
const count = cfg.values.length
const barWidth = (cfg.width - cfg.barGap * (count - 1)) / count
// Bars grow from the zero line: upwards for positive values, downwards
// (in the "negative" color) for negative ones. With no negative values the
// zero line is the bottom edge; otherwise it is drawn as a hairline.
const zeroY = yOf(0)
if (min < 0) {
this._hairline(svg, zeroY, 'zero')
}
// With a threshold, the bars below it take the "negative" color instead.
if (cfg.threshold !== null) {
this._hairline(svg, yOf(cfg.threshold), 'threshold', true)
}
// A bar spans from the zero line to its value, clipped to the chart when a
// forced `min` / `max` puts part of it (or the zero line) outside.
const clip = (y: number): number => Math.max(0, Math.min(cfg.height, y))
cfg.values.forEach((value, i) => {
const top = clip(yOf(Math.max(value, 0)))
const bottom = clip(yOf(Math.min(value, 0)))
const below = cfg.threshold === null ? value < 0 : value < cfg.threshold
const bar = svgEl('rect')
setAttr(bar, {
x: i * (barWidth + cfg.barGap),
y: top,
width: barWidth,
height: bottom - top,
rx: cfg.barRadius,
fill: cssVar(below ? 'negative' : 'stroke'),
})
svg.append(bar)
})
return svg
}
// Win/loss: every value is a full bar up (positive), down (negative) or a
// short tick on the zero line (zero), regardless of its size.
_renderTristate(): SVGSVGElement {
const cfg = this._config
const svg = this._createSvg()
const count = cfg.values.length
const barWidth = (cfg.width - cfg.barGap * (count - 1)) / count
const zeroY = cfg.height / 2
const tick = Math.min(2, zeroY)
this._hairline(svg, zeroY, 'zero')
cfg.values.forEach((value, i) => {
const bar = svgEl('rect')
const attrs = value > 0 ? { y: 0, height: zeroY, fill: 'stroke' } : value < 0 ? { y: zeroY, height: zeroY, fill: 'negative' } : { y: zeroY - tick / 2, height: tick, fill: 'track' }
setAttr(bar, {
x: i * (barWidth + cfg.barGap),
y: attrs.y,
width: barWidth,
height: attrs.height,
rx: cfg.barRadius,
fill: cssVar(attrs.fill),
})
svg.append(bar)
})
return svg
}
_renderCircle(): SVGSVGElement {
const cfg = this._config
const svg = this._createSvg()
const strokeWidth = getCssNumber(this._element, 'stroke-width', 3)
const radius = Math.max(0, Math.min(cfg.width, cfg.height) / 2 - strokeWidth / 2)
const cx = cfg.width / 2
const cy = cfg.height / 2
const ratio = this._circleRatio()
const circumference = 2 * Math.PI * radius
const track = svgEl('circle')
setAttr(track, {
'cx': cx,
'cy': cy,
'r': radius,
'fill': 'none',
'stroke': cssVar('track'),
'stroke-width': strokeWidth,
})
svg.append(track)
const ring = svgEl('circle')
setAttr(ring, {
'cx': cx,
'cy': cy,
'r': radius,
'fill': 'none',
'stroke': cssVar('stroke'),
'stroke-width': strokeWidth,
'stroke-linecap': 'round',
'stroke-dasharray': circumference,
'stroke-dashoffset': circumference * (1 - ratio),
'transform': `rotate(-90 ${cx} ${cy})`,
})
svg.append(ring)
return svg
}
}
/**
* Data API implementation
*/
// js-docs-start sparkline-init
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) {
Sparkline.getOrCreateInstance(element)
}
// js-docs-end sparkline-init
export default Sparkline
+2
View File
@@ -9,6 +9,7 @@ import './src/switch-icon'
import './src/tab'
import './src/toast'
import './src/sortable'
import './src/sparkline'
// Re-export everything from bootstrap.ts (single source of truth)
export * from './src/bootstrap'
@@ -18,4 +19,5 @@ export { default as Autosize } from './src/autosize'
export { default as CountUp } from './src/countup'
export { default as InputMask } from './src/input-mask'
export { default as Sortable } from './src/sortable'
export { default as Sparkline } from './src/sparkline'
export { default as SwitchIcon } from './src/switch-icon'
+234
View File
@@ -0,0 +1,234 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import { clearFixture, getFixture } from '../helpers/fixture'
import Sparkline from '../../src/sparkline'
describe('Sparkline', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
const el = (): HTMLElement => fixtureEl.querySelector('.sparkline')!
describe('NAME', () => {
it('should return plugin name', () => {
expect(Sparkline.NAME).toBe('sparkline')
})
})
describe('constructor', () => {
it('should render a line chart from data-bs-values', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-values="3,4,2,6"></span>'
const spy = vi.fn()
el().addEventListener('rendered.bs.sparkline', spy)
new Sparkline(el())
const svg = el().querySelector('svg')!
expect(svg.getAttribute('viewBox')).toBe('0 0 80 24')
expect(svg.querySelector('polyline')).not.toBeNull()
expect(svg.querySelector('path')).toBeNull()
expect(spy).toHaveBeenCalledTimes(1)
})
it('should render an area and a spot when asked', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-fill="auto" data-bs-spot="max" data-bs-values="1,5,2"></span>'
new Sparkline(el())
const svg = el().querySelector('svg')!
expect(svg.querySelector('path')).not.toBeNull()
const spot = svg.querySelector('circle')!
expect(spot.getAttribute('cx')).toBe('40')
expect(spot.getAttribute('cy')).toBe('2')
})
it('should render one rect per value for bars', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-values="[1, 2, 3]"></span>'
new Sparkline(el())
expect(el().querySelectorAll('rect').length).toBe(3)
})
it('should draw negative bars below the zero line in the negative color', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-values="2,-2" data-bs-height="20" data-bs-bar-gap="0"></span>'
new Sparkline(el())
const [up, down] = Array.from(el().querySelectorAll('rect'))
expect(up.getAttribute('y')).toBe('0')
expect(up.getAttribute('height')).toBe('10')
expect(up.getAttribute('fill')).toBe('var(--tblr-sparkline-stroke)')
expect(down.getAttribute('y')).toBe('10')
expect(down.getAttribute('height')).toBe('10')
expect(down.getAttribute('fill')).toBe('var(--tblr-sparkline-negative)')
const zero = el().querySelector('line')!
expect(zero.getAttribute('y1')).toBe('10')
expect(zero.getAttribute('stroke')).toBe('var(--tblr-sparkline-zero)')
})
it('should not draw a zero line without negative values', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-values="1,2,3"></span>'
new Sparkline(el())
expect(el().querySelector('line')).toBeNull()
})
it('should draw win, loss and tie bars for the tristate type', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-type="tristate" data-bs-values="5,-1,0" data-bs-height="20"></span>'
new Sparkline(el())
const [win, loss, tie] = Array.from(el().querySelectorAll('rect'))
expect([win.getAttribute('y'), win.getAttribute('height'), win.getAttribute('fill')]).toEqual(['0', '10', 'var(--tblr-sparkline-stroke)'])
expect([loss.getAttribute('y'), loss.getAttribute('height'), loss.getAttribute('fill')]).toEqual(['10', '10', 'var(--tblr-sparkline-negative)'])
expect([tie.getAttribute('y'), tie.getAttribute('height'), tie.getAttribute('fill')]).toEqual(['9', '2', 'var(--tblr-sparkline-track)'])
expect(el().querySelector('line')!.getAttribute('y1')).toBe('10')
})
it('should clip bars to the chart when min is above zero', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-values="99,100" data-bs-min="98" data-bs-max="100" data-bs-height="20"></span>'
new Sparkline(el())
const bars = Array.from(el().querySelectorAll('rect')).map((rect) => [rect.getAttribute('y'), rect.getAttribute('height')])
expect(bars).toEqual([
['10', '10'],
['0', '20'],
])
})
it('should draw a threshold line and color the bars below it', () => {
fixtureEl.innerHTML = '<span class="sparkline" id="line" data-bs-toggle="sparkline" data-bs-values="2,4,6" data-bs-threshold="8" data-bs-height="24" data-bs-pad="2"></span>' + '<span class="sparkline" id="bars" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-values="2,4,6" data-bs-threshold="5"></span>'
new Sparkline('#line')
new Sparkline('#bars')
// The range widens to include the threshold, so its line sits at the top padding.
const guide = fixtureEl.querySelector('#line line')!
expect(guide.getAttribute('y1')).toBe('2')
expect(guide.getAttribute('stroke')).toBe('var(--tblr-sparkline-threshold)')
const fills = Array.from(fixtureEl.querySelectorAll('#bars rect')).map((rect) => rect.getAttribute('fill'))
expect(fills).toEqual(['var(--tblr-sparkline-negative)', 'var(--tblr-sparkline-negative)', 'var(--tblr-sparkline-stroke)'])
})
it('should render a ring for the circle type', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-type="circle" data-bs-values="50" data-bs-width="32" data-bs-height="32"></span>'
new Sparkline(el())
const circles = el().querySelectorAll('circle')
expect(circles.length).toBe(2)
const dash = Number(circles[1].getAttribute('stroke-dasharray'))
const offset = Number(circles[1].getAttribute('stroke-dashoffset'))
expect(offset).toBeCloseTo(dash / 2, 5)
})
it('should derive an auto label from the data', () => {
fixtureEl.innerHTML = '<span class="sparkline" id="ring" data-bs-toggle="sparkline" data-bs-type="circle" data-bs-values="3" data-bs-max="4" data-bs-label="auto"></span>' + '<span class="sparkline" id="bars" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-values="1,2,7" data-bs-label="auto"></span>'
new Sparkline('#ring')
new Sparkline('#bars')
expect(fixtureEl.querySelector('#ring .sparkline-label')!.textContent).toBe('75%')
expect(fixtureEl.querySelector('#bars .sparkline-label')!.textContent).toBe('7')
})
it('should add a text label when asked', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-type="circle" data-bs-values="72" data-bs-label="72%"></span>'
new Sparkline(el())
const label = el().querySelector('.sparkline-label')!
expect(label.textContent).toBe('72%')
expect(el().querySelector('svg')).not.toBeNull()
})
it('should render nothing without values', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline"></span>'
new Sparkline(el())
expect(el().innerHTML).toBe('')
})
it('should accept values from the config object', () => {
fixtureEl.innerHTML = '<span class="sparkline"></span>'
new Sparkline(el(), { type: 'bar', values: [1, 2] })
expect(el().querySelectorAll('rect').length).toBe(2)
})
})
describe('update', () => {
it('should write the values back and re-render', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-values="1,2"></span>'
const spy = vi.fn()
el().addEventListener('updated.bs.sparkline', spy)
const sparkline = new Sparkline(el())
sparkline.update([1, 2, 3, 4])
expect(el().getAttribute('data-bs-values')).toBe('1,2,3,4')
expect(el().querySelectorAll('rect').length).toBe(4)
expect(spy).toHaveBeenCalledTimes(1)
})
it('should override values given to the constructor', () => {
fixtureEl.innerHTML = '<span class="sparkline"></span>'
const sparkline = new Sparkline(el(), { type: 'bar', values: [1, 2] })
sparkline.update('1,2,3')
expect(el().querySelectorAll('rect').length).toBe(3)
})
})
describe('animation', () => {
it('should keep the svg and tween its attributes on an update of the same shape', async () => {
fixtureEl.innerHTML = '<span class="sparkline"></span>'
const sparkline = new Sparkline(el(), { type: 'bar', values: [1, 2], animation: 50, barGap: 0 })
const svg = el().querySelector('svg')!
sparkline.update([2, 1])
expect(el().querySelector('svg')).toBe(svg)
await new Promise((resolve) => setTimeout(resolve, 200))
const heights = Array.from(svg.querySelectorAll('rect')).map((rect) => rect.getAttribute('height'))
expect(heights).toEqual(['24', '12'])
})
it('should replace the svg when the shape changes or animation is off', () => {
fixtureEl.innerHTML = '<span class="sparkline"></span>'
const sparkline = new Sparkline(el(), { type: 'bar', values: [1, 2], animation: 0 })
const svg = el().querySelector('svg')!
sparkline.update([2, 1])
expect(el().querySelector('svg')).not.toBe(svg)
})
})
describe('dispose', () => {
it('should empty the element and drop the instance', () => {
fixtureEl.innerHTML = '<span class="sparkline" data-bs-toggle="sparkline" data-bs-values="1,2"></span>'
const sparkline = new Sparkline(el())
sparkline.dispose()
expect(el().innerHTML).toBe('')
expect(Sparkline.getInstance(el())).toBeNull()
})
})
})
+1
View File
@@ -92,6 +92,7 @@
@forward 'ui/timeline';
@forward 'ui/type';
@forward 'ui/charts';
@forward 'ui/sparkline';
@forward 'ui/offcanvas';
@forward 'ui/chat';
@forward 'ui/signature';
+27
View File
@@ -2218,6 +2218,33 @@ $chart-sparkline-width-wide: 6rem !default;
$chart-sparkline-label-icon-size: 1rem !default;
// scss-docs-end chart-variables
// scss-docs-start sparkline-variables
$sparkline-width: 5rem !default;
$sparkline-height: 1.5rem !default;
$sparkline-stroke: currentColor !default;
$sparkline-negative: var(--red) !default;
$sparkline-zero: var(--border-color) !default;
$sparkline-threshold: var(--secondary) !default;
$sparkline-stroke-width: 2 !default;
$sparkline-fill: color-mix(in oklab, currentColor 10%, transparent) !default;
$sparkline-track: color-mix(in oklab, currentColor 15%, transparent) !default;
$sparkline-spot: currentColor !default;
$sparkline-spot-size: 2 !default;
$sparkline-label-font-size: $h6-font-size !default;
$sparkline-label-color: var(--body-color) !default;
$sparkline-disabled-color: var(--disabled-color) !default;
$sparkline-sizes: (
'sm': (
width: 3rem,
height: 1rem,
),
'lg': (
width: 8rem,
height: 2.5rem,
),
) !default;
// scss-docs-end sparkline-variables
// Patterns
$pattern-size: 0.5rem !default;
$pattern-size-sm: 0.25rem !default;
+71
View File
@@ -0,0 +1,71 @@
/**
Sparkline (inline SVG, rendered by js/src/sparkline.ts)
*/
@use 'sass:map';
@use '../config' as *;
.sparkline {
--sparkline-width: #{$sparkline-width};
--sparkline-height: #{$sparkline-height};
--sparkline-stroke: #{$sparkline-stroke};
--sparkline-negative: #{$sparkline-negative};
--sparkline-zero: #{$sparkline-zero};
--sparkline-threshold: #{$sparkline-threshold};
--sparkline-stroke-width: #{$sparkline-stroke-width};
--sparkline-fill: #{$sparkline-fill};
--sparkline-track: #{$sparkline-track};
--sparkline-spot: #{$sparkline-spot};
--sparkline-spot-size: #{$sparkline-spot-size};
--sparkline-label-font-size: #{$sparkline-label-font-size};
--sparkline-label-color: #{$sparkline-label-color};
position: relative;
display: inline-block;
flex-shrink: 0; // a fixed-size graphic: never squeezed by a flex row
width: var(--sparkline-width);
height: var(--sparkline-height);
line-height: 0;
vertical-align: middle;
.sparkline-svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
.sparkline-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--sparkline-label-font-size);
font-weight: $font-weight-medium;
font-variant-numeric: tabular-nums;
line-height: 1;
color: var(--sparkline-label-color);
}
}
@each $sparkline-size, $size in $sparkline-sizes {
.sparkline-#{$sparkline-size} {
--sparkline-width: #{map.get($size, width)};
--sparkline-height: #{map.get($size, height)};
}
}
// Same width as height, for the circle type
.sparkline-square {
--sparkline-width: var(--sparkline-height);
}
// Greyed out, for a series that is off or data that is stale
.sparkline-disabled {
--sparkline-stroke: #{$sparkline-disabled-color};
--sparkline-negative: #{$sparkline-disabled-color};
--sparkline-spot: #{$sparkline-disabled-color};
--sparkline-fill: color-mix(in oklab, #{$sparkline-disabled-color} 30%, transparent);
--sparkline-track: color-mix(in oklab, #{$sparkline-disabled-color} 40%, transparent);
--sparkline-label-color: #{$sparkline-disabled-color};
}
+249
View File
@@ -0,0 +1,249 @@
---
title: Sparkline
summary: A sparkline is a tiny chart drawn inline with text, for a trend in a table cell or next to a number. Tabler renders it as an SVG from data attributes, with no chart library.
description: Show a trend inline with a sparkline. Line, bar and circle charts rendered as SVG from data attributes, themed with text colors, sized with classes.
added-in: '1.6.0'
related: [/ui/plugins/chart, /ui/components/trending, /ui/components/progress]
source: core/scss/ui/_sparkline.scss
classnames:
component:
- class: sparkline
desc: Inline box that holds the chart, sized by --tblr-sparkline-width and --tblr-sparkline-height
part:
- class: sparkline-svg
desc: The rendered SVG, added by JavaScript
- class: sparkline-label
desc: Text centered over the chart, added by JavaScript for data-bs-label
state:
- class: sparkline-disabled
desc: Greys out the chart, the label and the area fill
size:
- class: sparkline-sm
desc: Small chart, 3rem by 1rem
- class: sparkline-lg
desc: Large chart, 8rem by 2.5rem
- class: sparkline-square
desc: Width equal to the height, for the circle type
---
import Example from '@components/Example.astro';
import CodeDocs from '@components/CodeDocs.astro';
## Default markup
Add `data-bs-toggle="sparkline"` and a comma-separated list of numbers in `data-bs-values`. Tabler draws the chart on page load. The chart uses `currentColor`, so a `text-{color}` utility sets its color. Put a short `aria-label` on the element, because the SVG itself is hidden from screen readers.
<Example centered>
<span class="sparkline text-primary" data-bs-toggle="sparkline" data-bs-values="3,4,2,6,5,8,7" aria-label="Weekly trend"></span>
</Example>
## Line
The default type. Add `data-bs-fill="auto"` for a tinted area under the line. Use `data-bs-spot` to mark one value with a dot: `last`, `min` or `max`.
<Example centered>
<span class="sparkline text-primary" data-bs-toggle="sparkline" data-bs-type="line" data-bs-values="12,14,9,18,16,22,20" aria-label="Trend"></span>
<span class="sparkline text-green" data-bs-toggle="sparkline" data-bs-type="line" data-bs-fill="auto" data-bs-spot="last" data-bs-values="12,14,9,18,16,22,20" aria-label="Trend, last value marked"></span>
<span class="sparkline text-red" data-bs-toggle="sparkline" data-bs-type="line" data-bs-fill="auto" data-bs-spot="min" data-bs-values="12,14,9,18,16,22,20" aria-label="Trend, lowest value marked"></span>
</Example>
## Bar
Set `data-bs-type="bar"`. Every value is a bar. Change the space between bars with `data-bs-bar-gap` and the corner rounding with `data-bs-bar-radius`, both in SVG units.
<Example centered>
<span class="sparkline text-orange" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-values="1,2,3,2,4,3,5" aria-label="Daily activity"></span>
<span class="sparkline text-purple" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-bar-gap="4" data-bs-bar-radius="0" data-bs-values="1,2,3,2,4,3,5" aria-label="Daily activity, square bars"></span>
</Example>
Negative values hang below a zero line. They use the `--tblr-sparkline-negative` color, red by default, and the line uses `--tblr-sparkline-zero`, the border color by default.
<Example centered>
<span class="sparkline text-green" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-values="3,-1,4,-2,5,-3,2,-1" aria-label="Daily change"></span>
<span class="sparkline text-primary" style="--tblr-sparkline-negative: var(--tblr-orange)" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-values="-2,4,-3,1,-1,5,-4,2" aria-label="Daily change, orange negatives"></span>
</Example>
## Win/loss
Set `data-bs-type="tristate"` for a win/loss chart. Every value is a full bar: up for a positive number, down in the negative color for a negative one, and a short tick on the zero line for zero. The size of the number does not matter.
<Example centered>
<span class="sparkline text-green" data-bs-toggle="sparkline" data-bs-type="tristate" data-bs-values="1,1,-1,1,0,-1,1,1,-1,1" aria-label="Match results"></span>
<span class="sparkline sparkline-lg text-primary" data-bs-toggle="sparkline" data-bs-type="tristate" data-bs-bar-gap="3" data-bs-values="3,-2,0,5,1,-1,-4,2,0,1,6,-3" aria-label="Deployments"></span>
</Example>
## Threshold
Set `data-bs-threshold` to a value to draw a dashed guide line there, in `--tblr-sparkline-threshold`. The range widens so the line is always inside the chart. On a bar chart the bars below the threshold take the negative color.
<Example centered>
<span class="sparkline text-primary" data-bs-toggle="sparkline" data-bs-fill="auto" data-bs-threshold="80" data-bs-values="62,71,68,84,79,88,92,75" aria-label="CPU usage, 80% limit"></span>
<span class="sparkline text-green" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-threshold="99.5" data-bs-min="98" data-bs-max="100" data-bs-values="99.9,99.7,99.2,99.8,99.4,99.95,99.6" aria-label="Uptime, 99.5% target"></span>
</Example>
## Circle
Set `data-bs-type="circle"` for a ring that shows one value as a share of `data-bs-max`, 100 by default. Add `sparkline-square` so the box is as wide as it is tall. The ring is as thick as `--tblr-sparkline-stroke-width`.
<Example centered>
<span class="sparkline sparkline-square text-red" data-bs-toggle="sparkline" data-bs-type="circle" data-bs-values="10" data-bs-width="24" data-bs-height="24" aria-label="10% done"></span>
<span class="sparkline sparkline-square text-yellow" data-bs-toggle="sparkline" data-bs-type="circle" data-bs-values="50" data-bs-width="24" data-bs-height="24" aria-label="50% done"></span>
<span class="sparkline sparkline-square text-primary" data-bs-toggle="sparkline" data-bs-type="circle" data-bs-values="72" data-bs-width="24" data-bs-height="24" aria-label="72% done"></span>
<span class="sparkline sparkline-square text-green" data-bs-toggle="sparkline" data-bs-type="circle" data-bs-values="100" data-bs-width="24" data-bs-height="24" aria-label="100% done"></span>
</Example>
Add `data-bs-label="auto"` to print the percentage in the middle of the ring. Any other text is printed as is, for example `data-bs-label="3/4"`. The label is plain text in the page font, sized by `--tblr-sparkline-label-font-size`, so it needs a `sparkline-lg` box to be readable.
<Example centered>
<span class="sparkline sparkline-lg sparkline-square text-red" style="--tblr-sparkline-stroke-width: 3" data-bs-toggle="sparkline" data-bs-type="circle" data-bs-values="10" data-bs-label="auto" data-bs-width="40" data-bs-height="40" aria-label="10% done"></span>
<span class="sparkline sparkline-lg sparkline-square text-yellow" style="--tblr-sparkline-stroke-width: 3" data-bs-toggle="sparkline" data-bs-type="circle" data-bs-values="50" data-bs-label="auto" data-bs-width="40" data-bs-height="40" aria-label="50% done"></span>
<span class="sparkline sparkline-lg sparkline-square text-primary" style="--tblr-sparkline-stroke-width: 3" data-bs-toggle="sparkline" data-bs-type="circle" data-bs-values="72" data-bs-label="auto" data-bs-width="40" data-bs-height="40" aria-label="72% done"></span>
<span class="sparkline sparkline-lg sparkline-square text-green" style="--tblr-sparkline-stroke-width: 3" data-bs-toggle="sparkline" data-bs-type="circle" data-bs-values="100" data-bs-label="auto" data-bs-width="40" data-bs-height="40" aria-label="100% done"></span>
</Example>
## Sizes
Add `sparkline-sm` or `sparkline-lg`. The SVG scales to the box. For any other size, set `--tblr-sparkline-width` and `--tblr-sparkline-height` on the element.
<Example centered>
<span class="sparkline sparkline-sm text-primary" data-bs-toggle="sparkline" data-bs-values="2,3,2,4,3,5,4" aria-label="Small"></span>
<span class="sparkline text-primary" data-bs-toggle="sparkline" data-bs-values="2,3,2,4,3,5,4" aria-label="Default"></span>
<span class="sparkline sparkline-lg text-primary" data-bs-toggle="sparkline" data-bs-values="2,3,2,4,3,5,4" aria-label="Large"></span>
<span class="sparkline text-primary" style="--tblr-sparkline-width: 12rem; --tblr-sparkline-height: 2rem" data-bs-toggle="sparkline" data-bs-values="2,3,2,4,3,5,4" aria-label="Custom size"></span>
</Example>
## Disabled
Add `sparkline-disabled` to grey out a chart, for example for a series the user switched off or data that is stale. Every color, including negative bars and the label, becomes `--tblr-disabled-color`.
<Example centered>
<span class="sparkline sparkline-disabled text-primary" data-bs-toggle="sparkline" data-bs-fill="auto" data-bs-spot="last" data-bs-values="12,14,9,18,16,22,20" aria-label="Trend, disabled"></span>
<span class="sparkline sparkline-disabled text-orange" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-values="3,-1,4,-2,5,-3,2" aria-label="Daily change, disabled"></span>
<span class="sparkline sparkline-disabled sparkline-lg sparkline-square text-green" style="--tblr-sparkline-stroke-width: 3" data-bs-toggle="sparkline" data-bs-type="circle" data-bs-values="72" data-bs-label="auto" data-bs-width="40" data-bs-height="40" aria-label="72% done, disabled"></span>
</Example>
## Fixed range
By default the chart scales to its own lowest and highest value. Set `data-bs-min` and `data-bs-max` when several charts must share one scale, for example percentages.
<Example centered>
<span class="sparkline text-primary" data-bs-toggle="sparkline" data-bs-min="0" data-bs-max="100" data-bs-values="10,20,35,30,45,60,55" aria-label="Series A, 0 to 100"></span>
<span class="sparkline text-red" data-bs-toggle="sparkline" data-bs-min="0" data-bs-max="100" data-bs-values="5,15,25,40,70,65,80" aria-label="Series B, 0 to 100"></span>
</Example>
## In a table
A sparkline is an inline block aligned to the middle of the text, so it fits a table cell or a stat row without extra wrappers.
<Example>
<table class="table table-vcenter">
<thead>
<tr>
<th>Metric</th>
<th class="text-end">Value</th>
<th class="text-end">Trend</th>
</tr>
</thead>
<tbody>
<tr>
<td>Revenue</td>
<td class="text-end">$12.4k</td>
<td class="text-end"><span class="sparkline text-green" data-bs-toggle="sparkline" data-bs-fill="auto" data-bs-values="5,6,7,6,8,9,10" aria-label="Revenue trend"></span></td>
</tr>
<tr>
<td>Errors</td>
<td class="text-end">31</td>
<td class="text-end"><span class="sparkline text-red" data-bs-toggle="sparkline" data-bs-type="bar" data-bs-values="2,3,5,4,8,13,21" aria-label="Errors trend"></span></td>
</tr>
</tbody>
</table>
</Example>
## Options
Every option is a `data-bs-*` attribute on the element, or a key in the config object passed to the constructor.
| Option | Default | Description |
| --- | --- | --- |
| `values` | `[]` | The numbers to draw. A comma-separated list or a JSON array, for example `3,4,2` or `[3,4,2]`. |
| `type` | `line` | `line`, `bar`, `tristate` or `circle`. |
| `fill` | `none` | `auto` fills the area under a line with `--tblr-sparkline-fill`. |
| `spot` | `none` | Marks one value on a line with a dot: `last`, `min` or `max`. |
| `min`, `max` | auto | The value range. Bars always include zero. For a circle, `max` is the full ring. |
| `width`, `height` | `80`, `24` | The SVG viewBox in units. Change the ratio for a very wide or a square chart. |
| `pad` | `2` | Space above and below a line, in units, so the stroke and the dot are not cut off. |
| `barGap` | `2` | Space between bars, in units. |
| `barRadius` | `2` | Corner radius of a bar, in units. |
| `label` | none | Text centered over the chart. `auto` prints the share of `max` for a circle and the last value for other types. |
| `threshold` | none | Draws a guide line at this value. Bars below it use the negative color. |
| `animation` | `300` | How long an `update()` tweens the chart, in milliseconds. `0` redraws at once. Off under `prefers-reduced-motion`. |
## JavaScript
Tabler wraps the element in a `Sparkline` component that works like the Bootstrap components: it is created once per element and stored on it. On page load Tabler creates one for every element with `data-bs-toggle="sparkline"` and renders the chart. This is the code that runs:
<CodeDocs name="sparkline-init" file="core/js/src/sparkline.ts" />
Create the component yourself for an element added later, or pass the values from code:
```js
const orders = tabler.Sparkline.getOrCreateInstance(document.getElementById('orders'), {
type: 'bar',
values: [3, 4, 2, 6, 5, 8, 7],
});
```
Call `update()` with new values to redraw the chart. It writes the values back to `data-bs-values` and fires `updated.bs.sparkline`. When the type and the number of values stay the same, the chart tweens to the new shape over `animation` milliseconds:
```js
orders.update([4, 2, 7, 5, 9, 6, 8]);
document.getElementById('orders').addEventListener('updated.bs.sparkline', () => {
console.log('chart redrawn');
});
```
| Method | Description |
| --- | --- |
| `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. |
| `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. |
| Event | Description |
| --- | --- |
| `rendered.bs.sparkline` | Fired on the element after every render. |
| `updated.bs.sparkline` | Fired on the element after `update()`. |
## Custom properties
The chart reads its colors and stroke widths from custom properties on the element, so you can change them per chart with a `style` attribute or per section with a class.
| Property | Default | Description |
| --- | --- | --- |
| `--tblr-sparkline-width` | `5rem` | Width of the box. |
| `--tblr-sparkline-height` | `1.5rem` | Height of the box. |
| `--tblr-sparkline-stroke` | `currentColor` | Line, bar and ring color. |
| `--tblr-sparkline-stroke-width` | `2` | Line and ring thickness, in SVG units. |
| `--tblr-sparkline-fill` | 10% of `currentColor` | Area under a filled line. |
| `--tblr-sparkline-negative` | `var(--tblr-red)` | Bars for negative values. |
| `--tblr-sparkline-zero` | `var(--tblr-border-color)` | The zero line under signed bars. |
| `--tblr-sparkline-threshold` | `var(--tblr-secondary)` | The dashed guide line set with `threshold`. |
| `--tblr-sparkline-track` | 15% of `currentColor` | The unfilled part of a circle. |
| `--tblr-sparkline-spot` | `currentColor` | The dot set with `spot`. |
| `--tblr-sparkline-spot-size` | `2` | Radius of the dot, in SVG units. |
| `--tblr-sparkline-label-font-size` | `0.625rem` | Font size of the label. |
| `--tblr-sparkline-label-color` | `var(--tblr-body-color)` | Color of the label. |
## Accessibility
- The SVG is hidden from assistive technology with `aria-hidden`. Give the element an `aria-label` that says what the chart shows, for example `aria-label="Revenue, last 7 days"`, or put the numbers in a visible cell next to it.
- A sparkline shows a shape, not exact values. Keep the real number in the text nearby, or in `data-bs-label` for a circle, so the chart is a hint and not the only source.
- Color alone should not carry meaning. For signed bars the negative values also hang below the zero line, and a threshold is a visible line, which is what makes them readable.
- Updates animate for `animation` milliseconds. The tween is skipped when the user asks for reduced motion.
## Variables
Use these SCSS variables to customize sparklines. The default values are:
<CodeDocs name="sparkline-variables" file="core/scss/_variables.scss" />
+1 -1
View File
@@ -15,7 +15,7 @@ import { requireIndex } from '@shared/lib/array'
import DocsLink from '@ui/DocsLink.astro'
---
<DefaultLayout title="Card Gradients" pageMenu="base.cards.gradients" pageLibs={['apexcharts']}>
<DefaultLayout title="Card Gradients" pageMenu="base.cards.gradients">
<DocsLink slot="page-header-actions" path="/ui/components/card-gradient" />
<div class="row row-cards">
<div class="col-md-6">
+262
View File
@@ -0,0 +1,262 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import CaptureScript from '@shared/components/CaptureScript.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import DocsLink from '@ui/DocsLink.astro'
import Badge from '@ui/Badge.astro'
import Sparkline from '@ui/Sparkline.astro'
import type { SparklineType } from '@ui/Sparkline.astro'
import site from '@data/site.json'
import { ucFirst } from '@shared/lib/string-format'
const colors = Object.keys(site.colors)
const metrics: { name: string; value: string; color: string; type: SparklineType; fill?: string; values: string }[] = [
{ name: 'Revenue', value: '$12.4k', color: 'success', type: 'line', fill: 'auto', values: '5,6,7,6,8,9,10' },
{ name: 'Orders', value: '1,352', color: 'primary', type: 'bar', values: '3,4,2,6,5,8,7' },
{ name: 'Errors', value: '31', color: 'danger', type: 'bar', values: '2,3,5,4,8,13,21' },
{ name: 'CPU usage', value: '72%', color: 'info', type: 'line', values: '40,55,50,70,65,80,72' },
]
const progress = [
{ label: 'Completion 10%', value: 10, color: 'danger' },
{ label: 'Completion 50%', value: 50, color: 'warning' },
{ label: 'Completion 72%', value: 72, color: 'primary' },
{ label: 'Completion 100%', value: 100, color: 'success' },
]
---
<DefaultLayout title="Sparkline" pageMenu="base.sparkline" description="Sparklines are tiny inline SVG charts rendered from data attributes, with no chart library needed.">
<DocsLink slot="page-header-actions" path="/ui/components/sparkline" />
<div class="row row-cards">
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Line</CardTitle>
<CardSubtitle>Add <code>data-bs-toggle="sparkline"</code> and a comma-separated <code>data-bs-values</code> list.</CardSubtitle>
<Sparkline values="3,4,2,6,5,8,7" color="primary" ariaLabel="Weekly trend" />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Area with spot</CardTitle>
<CardSubtitle>Use <code>data-bs-fill="auto"</code> for a tinted area and <code>data-bs-spot</code> to mark the <code>last</code>, <code>min</code> or <code>max</code> value.</CardSubtitle>
<div class="d-flex align-items-center gap-3">
<Sparkline values="12,14,9,18,16,22,20" color="success" fill spot="last" ariaLabel="Last value" />
<Sparkline values="12,14,9,18,16,22,20" color="danger" fill spot="min" ariaLabel="Lowest value" />
<Sparkline values="12,14,9,18,16,22,20" color="azure" fill spot="max" ariaLabel="Highest value" />
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Bars</CardTitle>
<CardSubtitle>Set <code>data-bs-type="bar"</code>. Tune the gap and rounding with <code>data-bs-bar-gap</code> and <code>data-bs-bar-radius</code>.</CardSubtitle>
<div class="d-flex align-items-center gap-3">
<Sparkline values="1,2,3,2,4,3,5" type="bar" color="warning" ariaLabel="Daily activity" />
<Sparkline values="1,2,3,2,4,3,5" type="bar" color="purple" barGap={4} barRadius={0} ariaLabel="Daily activity, square bars" />
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Signed bars</CardTitle>
<CardSubtitle>Negative values hang below a zero line in <code>--tblr-sparkline-negative</code>, red by default; the line uses <code>--tblr-sparkline-zero</code>.</CardSubtitle>
<div class="d-flex flex-wrap align-items-center gap-3">
<Sparkline values="3,-1,4,-2,5,-3,2,-1" type="bar" color="success" ariaLabel="Daily change" />
<Sparkline values="-2,4,-3,1,-1,5,-4,2" type="bar" color="primary" style="--tblr-sparkline-negative: var(--tblr-orange)" ariaLabel="Daily change, orange negatives" />
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Win/loss</CardTitle>
<CardSubtitle>Set <code>data-bs-type="tristate"</code>: every value is a full bar up, down or a tick on the zero line, whatever its size.</CardSubtitle>
<div class="d-flex flex-wrap align-items-center gap-3">
<Sparkline values="1,1,-1,1,0,-1,1,1,-1,1" type="tristate" color="green" ariaLabel="Match results" />
<Sparkline values="3,-2,0,5,1,-1,-4,2,0,1,6,-3" type="tristate" color="primary" size="lg" barGap={3} ariaLabel="Deployments, large" />
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Threshold</CardTitle>
<CardSubtitle>Set <code>data-bs-threshold</code> to draw a guide line at a value. Bars below it take the negative color.</CardSubtitle>
<div class="d-flex flex-wrap align-items-center gap-3">
<Sparkline values="62,71,68,84,79,88,92,75" color="primary" fill threshold={80} ariaLabel="CPU usage, 80% limit" />
<Sparkline values="99.9,99.7,99.2,99.8,99.4,99.95,99.6" type="bar" color="green" min={98} max={100} threshold={99.5} ariaLabel="Uptime, 99.5% target" />
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Circle</CardTitle>
<CardSubtitle>Use <code>data-bs-type="circle"</code> with a single value as a share of <code>data-bs-max</code>, <code>sparkline-square</code> for a round box and <code>data-bs-label="auto"</code> for the percentage in the middle.</CardSubtitle>
<div class="d-flex flex-wrap align-items-center gap-3">
{progress.map((item) => <Sparkline type="circle" values={[item.value]} color={item.color} ariaLabel={item.label} />)}
{progress.map((item) => <Sparkline type="circle" values={[item.value]} color={item.color} size="lg" label="auto" style="--tblr-sparkline-stroke-width: 3" ariaLabel={item.label} />)}
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Disabled</CardTitle>
<CardSubtitle>Add <code>sparkline-disabled</code> to grey out a chart, for a series that is off or data that is stale.</CardSubtitle>
<div class="d-flex flex-wrap align-items-center gap-3">
<Sparkline values="12,14,9,18,16,22,20" color="primary" fill spot="last" disabled ariaLabel="Trend, disabled" />
<Sparkline values="3,-1,4,-2,5,-3,2" type="bar" color="warning" disabled ariaLabel="Daily change, disabled" />
<Sparkline type="circle" values="72" color="green" size="lg" label="auto" disabled style="--tblr-sparkline-stroke-width: 3" ariaLabel="Completion 72%, disabled" />
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Colors</CardTitle>
<CardSubtitle>The chart uses <code>currentColor</code>, so any <code>text-{'{color}'}</code> utility themes it.</CardSubtitle>
<div class="d-flex flex-wrap align-items-center gap-3">
{colors.map((color) => <Sparkline values="2,3,2,4,3,5,4" color={color} fill ariaLabel={ucFirst(color)} />)}
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Fixed range</CardTitle>
<CardSubtitle>Set <code>data-bs-min</code> and <code>data-bs-max</code> so several charts share the same scale.</CardSubtitle>
<div class="d-flex align-items-center gap-3">
<Sparkline values="10,20,35,30,45,60,55" color="primary" min={0} max={100} ariaLabel="Series A, 0 to 100" />
<Sparkline values="5,15,25,40,70,65,80" color="danger" min={0} max={100} ariaLabel="Series B, 0 to 100" />
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Sizes</CardTitle>
<CardSubtitle>Add <code>sparkline-sm</code> or <code>sparkline-lg</code>; for a custom box override <code>--tblr-sparkline-width</code> and <code>--tblr-sparkline-height</code>.</CardSubtitle>
<div class="d-flex flex-wrap align-items-center gap-3">
<Sparkline values="2,3,2,4,3,5,4" color="info" size="sm" ariaLabel="Small" />
<Sparkline values="2,3,2,4,3,5,4" color="info" ariaLabel="Default" />
<Sparkline values="2,3,2,4,3,5,4" color="info" size="lg" ariaLabel="Large" />
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Stroke width</CardTitle>
<CardSubtitle>Change <code>--tblr-sparkline-stroke-width</code> and <code>--tblr-sparkline-spot-size</code> in SVG units.</CardSubtitle>
<div class="d-flex align-items-center gap-3">
<Sparkline values="3,4,2,6,5,8,7" color="primary" spot="last" style="--tblr-sparkline-stroke-width: 1; --tblr-sparkline-spot-size: 1.5" ariaLabel="Thin" />
<Sparkline values="3,4,2,6,5,8,7" color="primary" spot="last" ariaLabel="Default" />
<Sparkline values="3,4,2,6,5,8,7" color="primary" spot="last" style="--tblr-sparkline-stroke-width: 4; --tblr-sparkline-spot-size: 3" ariaLabel="Thick" />
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Update from JavaScript</CardTitle>
<CardSubtitle>Call <code>update(values)</code> on the instance. It writes <code>data-bs-values</code> back, tweens the chart for <code>data-bs-animation</code> milliseconds and fires <code>updated.bs.sparkline</code>.</CardSubtitle>
<div class="d-flex align-items-center gap-3">
<Sparkline id="sparkline-orders" values="3,4,2,6,5,8,7,9,6,8" color="primary" size="lg" fill spot="last" ariaLabel="Orders" />
<button id="sparkline-update" type="button" class="btn btn-sm btn-outline-primary">Randomize</button>
<Badge id="sparkline-updates" color="secondary" light text="0 updates" />
</div>
<CaptureScript>
<!-- BEGIN SCRIPT OF SPARKLINE -->
<script is:inline>
/*
Pushes random data into a sparkline and counts the updated.bs.sparkline events.
tabler.js is deferred, so the instance is looked up once the DOM is ready.
*/
document.addEventListener('DOMContentLoaded', () => {
const element = document.getElementById('sparkline-orders')
const sparkline = tabler.Sparkline.getOrCreateInstance(element)
let updates = 0
document.getElementById('sparkline-update').addEventListener('click', () => {
sparkline.update(Array.from({ length: 10 }, () => Math.floor(Math.random() * 30)))
})
element.addEventListener('updated.bs.sparkline', () => {
updates += 1
document.getElementById('sparkline-updates').textContent = `${updates} update${updates === 1 ? '' : 's'}`
})
})
</script>
<!-- END SCRIPT OF SPARKLINE -->
</CaptureScript>
</CardBody>
</Card>
</div>
<div class="col-lg-8">
<Card>
<CardBody>
<CardTitle>In a table</CardTitle>
<CardSubtitle>A sparkline sits inline with text, so it works in table cells and stat rows.</CardSubtitle>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Metric</th>
<th class="text-end">Value</th>
<th class="text-end">Trend</th>
</tr>
</thead>
<tbody>
{
metrics.map((metric) => (
<tr>
<td>{metric.name}</td>
<td class="text-end">{metric.value}</td>
<td class="text-end">
<Sparkline values={metric.values} type={metric.type} color={metric.color} fill={metric.fill === 'auto'} ariaLabel={`${metric.name} trend`} />
</td>
</tr>
))
}
</tbody>
</table>
</div>
</CardBody>
</Card>
</div>
</div>
</DefaultLayout>
+8 -8
View File
@@ -14,7 +14,7 @@ import UserInfo from '@shared/components/cards/UserInfo.astro'
import Configuration from '@shared/components/cards/Configuration.astro'
---
<DefaultLayout title="Widgets" pageMenu="extra.widgets" pageLibs={['apexcharts']}>
<DefaultLayout title="Widgets" pageMenu="extra.widgets">
<div class="row row-cards">
<div class="col-12 col-md-6 col-xxl-3 mb-4 mb-xxl-0">
<StatCard title="Earned" value="$2,847" icon="credit-card" countup />
@@ -72,16 +72,16 @@ import Configuration from '@shared/components/cards/Configuration.astro'
</div>
<div class="col-md-6 col-xl-3">
<SmallStats id="sales" chartPosition="left" chartType="donut" chartData="56" color="primary" title="132 Sales" description="12 waiting payments" />
<SmallStats id="sales" chartPosition="left" chartType="circle" chartData="56" color="primary" title="132 Sales" description="12 waiting payments" />
</div>
<div class="col-md-6 col-xl-3">
<SmallStats id="orders" chartPosition="left" chartType="donut" chartData="56" color="green" title="78 Orders" description="32 shipped" />
<SmallStats id="orders" chartPosition="left" chartType="circle" chartData="56" color="green" title="78 Orders" description="32 shipped" />
</div>
<div class="col-md-6 col-xl-3">
<SmallStats id="members" chartPosition="left" chartType="donut" chartData="56" color="red" title="1352 Members" description="163 registered today" />
<SmallStats id="members" chartPosition="left" chartType="circle" chartData="56" color="red" title="1352 Members" description="163 registered today" />
</div>
<div class="col-md-6 col-xl-3">
<SmallStats id="comments" chartPosition="left" chartType="donut" chartData="56" color="yellow" title="132 Comments" description="16 waitings" />
<SmallStats id="comments" chartPosition="left" chartType="circle" chartData="56" color="yellow" title="132 Comments" description="16 waitings" />
</div>
<div class="col-md-6 col-xl-3">
@@ -102,9 +102,9 @@ import Configuration from '@shared/components/cards/Configuration.astro'
<div class="col-12"><SmallStats id="widget-line" chartType="line" chartData="20,0,40,30,40,30,80,60" /></div>
<div class="col-12"><SmallStats id="widget-bar-red" chartType="bar" chartData="20,40,30,10,40,60,80,70" color="red" icon="heart" /></div>
<div class="col-12"><SmallStats id="widget-bar-green" chartType="bar" chartData="20,40,20,-10,-30,10,40,60,80,70" color="green" icon="brand-github" /></div>
<div class="col-12"><SmallStats id="widget-donut-10" chartType="donut" chartData="12" personId={10} /></div>
<div class="col-12"><SmallStats id="widget-donut-11" chartType="donut" chartData="12" personId={11} /></div>
<div class="col-12"><SmallStats id="widget-donut-1" chartType="donut" chartData="56" personId={1} /></div>
<div class="col-12"><SmallStats id="widget-donut-10" chartType="circle" chartData="12" personId={10} /></div>
<div class="col-12"><SmallStats id="widget-donut-11" chartType="circle" chartData="12" personId={11} /></div>
<div class="col-12"><SmallStats id="widget-donut-1" chartType="circle" chartData="56" personId={1} /></div>
<div class="col-12"><SmallStats color="green" personId={4} /></div>
<div class="col-12"><SmallStats smallIcon="trending-down" color="red" personId={3} /></div>
<div class="col-12"><Code /></div>
+53
View File
@@ -0,0 +1,53 @@
---
// Sparkline component on its own: a grid of the chart types — a plain line, a
// filled line with the last value marked, bars (one set with negative
// values below the zero line), a circle and a win/loss chart, then a row
// with few and many data points — colored in palette order, since the charts draw with
// currentColor. The
// charts use the `sparkline-lg` size (and `sparkline-square` for the circles)
// with a thicker stroke so they read at picture size.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
const charts = [
{ color: 'blue', type: 'line', values: '14,9,17,11,22,8,19,13,24,16,10,21' },
{ color: 'azure', type: 'line', fill: 'auto', spot: 'last', values: '7,15,11,23,9,18,26,14,20,12,29,33' },
{ color: 'indigo', type: 'bar', values: '5,12,8,3,14,9,6,15,4,11' },
{ color: 'purple', type: 'circle', values: '64', label: 'auto' },
{ color: 'pink', type: 'line', spot: 'max', values: '21,13,27,18,9,31,15,24,12,19,26,17' },
{ color: 'orange', type: 'line', fill: 'auto', spot: 'min', values: '38,29,34,17,25,11,22,15,8,19,13,16' },
{ color: 'teal', type: 'bar', values: '6,-3,8,-5,2,-7,9,-2,4,-6' },
{ color: 'green', type: 'circle', values: '27', label: 'auto' },
{ color: 'red', type: 'line', spot: 'last', values: '9,4,12,7,15' },
{ color: 'yellow', type: 'line', fill: 'auto', values: '14,11,17,9,20,13,22,10,18,25,15,27,12,23,30,16,24,19,28,21,33,17,26,31,22,35,20,29,34,26' },
{ color: 'lime', type: 'tristate', barGap: 3, values: '3,-2,0,5,1,-1,-4,2,0,1' },
{ color: 'cyan', type: 'bar', barGap: 1.5, values: '3,6,2,8,5,9,4,7,10,3,8,6,11,5,9,2,7,10,4,8,6,12,5,9' },
]
const isCircle = (type: string) => type === 'circle'
---
<ScreenshotLayout title="Sparkline" columns={2.4}>
<div class="d-grid gap-6 justify-content-center align-items-center" style="grid-template-columns: repeat(4, auto); justify-items: center">
{
charts.map((chart) => (
<span
class:list={['sparkline', 'sparkline-lg', isCircle(chart.type) && 'sparkline-square', `text-${chart.color}`]}
style={isCircle(chart.type) ? '--tblr-sparkline-stroke-width: 4' : '--tblr-sparkline-stroke-width: 2.5; --tblr-sparkline-spot-size: 3'}
data-bs-toggle="sparkline"
data-bs-type={chart.type}
data-bs-fill={chart.fill}
data-bs-spot={chart.spot}
data-bs-values={chart.values}
data-bs-label={chart.label}
data-bs-max={isCircle(chart.type) ? 100 : undefined}
data-bs-width={isCircle(chart.type) ? 40 : 120}
data-bs-height={isCircle(chart.type) ? 40 : 40}
data-bs-pad={isCircle(chart.type) ? undefined : 3}
data-bs-bar-gap={chart.type === 'bar' || chart.type === 'tristate' ? (chart.barGap ?? 4) : undefined}
data-bs-bar-radius={chart.type === 'bar' || chart.type === 'tristate' ? 3 : undefined}
aria-label={`${chart.type} sparkline`}
/>
))
}
</div>
</ScreenshotLayout>
@@ -1,7 +1,7 @@
---
import { personById } from '@shared/lib/people'
import Chart from '@shared/components/demo/Chart.astro'
import ChartSparkline from '@ui/ChartSparkline.astro'
import Sparkline from '@ui/Sparkline.astro'
import Icon from '@ui/Icon.astro'
import Avatar from '@ui/Avatar.astro'
import commits from '@data/commits.json'
@@ -26,7 +26,7 @@ const rows = (commits as Commit[]).slice(0, 5)
<div class="position-relative">
<div class="position-absolute top-0 start-0 px-3 mt-1 w-75">
<div class="row g-2">
<div class="col-auto"><ChartSparkline id="activity" percentage={35} type="donut" /></div>
<div class="col-auto"><Sparkline id="activity" type="circle" values={[35]} size="lg" color="primary" label="auto" ariaLabel="35% of today's goal" /></div>
<div class="col">
<div>Today's Earning: $4,262.40</div>
<div class="text-secondary"><Icon name="trending-up" color="green" class="icon-inline" /> +5% more than yesterday</div>
@@ -1,6 +1,6 @@
---
import Icon from '@ui/Icon.astro'
import ChartSparkline from '@ui/ChartSparkline.astro'
import Sparkline from '@ui/Sparkline.astro'
import CardTitle from '@ui/CardTitle.astro'
import urls from '@data/urls.json'
import { formatNumber } from '@shared/lib/string-format'
@@ -34,7 +34,7 @@ import { formatNumber } from '@shared/lib/string-format'
<td class="text-secondary">{formatNumber(url.unique)}</td>
<td class="text-secondary">{url.bounce}</td>
<td class="text-end w-1">
<ChartSparkline type="line" data={url.data} id={`bounce-rate-${index + 1}`} size="sm" color="primary" />
<Sparkline values={url.data} id={`bounce-rate-${index + 1}`} size="sm" color="primary" ariaLabel={`Bounce rate trend for ${url.uri}`} />
</td>
</tr>
))
+5 -4
View File
@@ -1,9 +1,9 @@
---
import { personById } from '@shared/lib/people'
import type { SparklineType } from '@ui/ChartSparkline.astro'
import type { SparklineType } from '@ui/Sparkline.astro'
import Icon from '@ui/Icon.astro'
import Avatar from '@ui/Avatar.astro'
import ChartSparkline from '@ui/ChartSparkline.astro'
import Sparkline from '@ui/Sparkline.astro'
interface Props {
icon?: string
@@ -14,6 +14,7 @@ interface Props {
class?: string
id?: string
personId?: number
/** chart drawn next to the number; `circle` shows chartData as a share of 100 */
chartType?: SparklineType
chartPosition?: string
chartData?: string | number
@@ -44,7 +45,7 @@ const avatarClass = [color ? `bg-${color}${lt ? '-lt' : ' text-white'}` : '', 'a
</div>
) : chartData && chartPosition === 'left' ? (
<div class="col-auto">
<ChartSparkline id={id} data={chartData} type={chartType} color={color} />
<Sparkline id={id} values={String(chartData)} type={chartType} color={color} size={chartType === 'circle' ? 'lg' : undefined} fill={chartType === 'line'} label={chartType === 'circle' ? 'auto' : undefined} ariaLabel={String(description ?? 'Trend')} />
</div>
) : null
}
@@ -63,7 +64,7 @@ const avatarClass = [color ? `bg-${color}${lt ? '-lt' : ' text-white'}` : '', 'a
{
chartData && chartPosition === 'right' && (
<div class="col-auto">
<ChartSparkline id={id} data={chartData} type={chartType} color={chartColor} />
<Sparkline id={id} values={String(chartData)} type={chartType} color={chartColor} size={chartType === 'circle' ? 'lg' : undefined} fill={chartType === 'line'} label={chartType === 'circle' ? 'auto' : undefined} ariaLabel={String(description ?? 'Trend')} />
</div>
)
}
+4
View File
@@ -264,6 +264,10 @@
"title": "Segmented control",
"url": "/ui/components/segmented-control"
},
{
"title": "Sparkline",
"url": "/ui/components/sparkline"
},
{
"title": "Spinner",
"url": "/ui/components/spinner"
+4
View File
@@ -213,6 +213,10 @@
"title": "Social icons",
"url": "social-icons.html"
},
"sparkline": {
"title": "Sparkline",
"url": "sparkline.html"
},
"stars-rating": {
"title": "Stars rating",
"url": "stars-rating.html"
+69
View File
@@ -0,0 +1,69 @@
---
// Sparkline (.sparkline): a tiny inline SVG chart drawn by core/js/src/sparkline.ts
// from the data attributes this component emits. No chart library needed.
/** core/js/src/sparkline.ts chart types */
export type SparklineType = 'line' | 'bar' | 'tristate' | 'circle'
/** core/scss/ui/_sparkline.scss `.sparkline-{size}` modifier classes */
export type SparklineSize = 'sm' | 'lg'
interface Props {
/** the numbers to draw, as an array or a comma-separated string */
values: number[] | string
type?: SparklineType
/** text-{color} on the element; the chart draws with currentColor */
color?: string | undefined
size?: SparklineSize | undefined
/** sparkline-square: width equal to the height, for the circle type */
square?: boolean | undefined
/** data-bs-fill="auto": tinted area under a line */
fill?: boolean | undefined
/** data-bs-spot: mark one value on a line with a dot */
spot?: 'last' | 'min' | 'max' | undefined
/** data-bs-min / data-bs-max: a fixed value range */
min?: number | undefined
max?: number | undefined
/** data-bs-threshold: a dashed guide line at this value */
threshold?: number | undefined
/** data-bs-label: text centered over the chart; `auto` derives it from the data */
label?: string | number | 'auto' | undefined
/** data-bs-bar-gap / data-bs-bar-radius, in SVG units */
barGap?: number | undefined
barRadius?: number | undefined
/** data-bs-animation: tween length of update() in ms; 0 turns it off */
animation?: number | undefined
/** sparkline-disabled: greys the chart out */
disabled?: boolean | undefined
/** accessible name; the SVG itself is hidden from assistive technology */
ariaLabel?: string | undefined
class?: string
/** remaining attributes (id, style, data-*, …) are forwarded to the root element */
[key: string]: unknown
}
const { values, type = 'line', color, size, square, fill, spot, min, max, threshold, label, barGap, barRadius, animation, disabled, ariaLabel, class: className, ...rest } = Astro.props
const serialized = Array.isArray(values) ? values.join(',') : values
const isCircle = type === 'circle'
const classes = ['sparkline', size && `sparkline-${size}`, (square || isCircle) && 'sparkline-square', disabled && 'sparkline-disabled', color && `text-${color}`, className]
---
<span
class:list={classes}
data-bs-toggle="sparkline"
data-bs-type={type === 'line' ? undefined : type}
data-bs-values={serialized}
data-bs-fill={fill ? 'auto' : undefined}
data-bs-spot={spot}
data-bs-min={min}
data-bs-max={max}
data-bs-threshold={threshold}
data-bs-label={label}
data-bs-bar-gap={barGap}
data-bs-bar-radius={barRadius}
data-bs-animation={animation}
data-bs-width={isCircle ? 40 : undefined}
data-bs-height={isCircle ? 40 : undefined}
aria-label={ariaLabel}
{...rest}></span>