mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Add sparkline component (#3051)
This commit is contained in:
1 parent
2129b0b881
commit
5d5be36332
19 files changed
+1633
-20
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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" />
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -264,6 +264,10 @@
|
||||
"title": "Segmented control",
|
||||
"url": "/ui/components/segmented-control"
|
||||
},
|
||||
{
|
||||
"title": "Sparkline",
|
||||
"url": "/ui/components/sparkline"
|
||||
},
|
||||
{
|
||||
"title": "Spinner",
|
||||
"url": "/ui/components/spinner"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user