diff --git a/.agents/skills/write-docs/SKILL.md b/.agents/skills/write-docs/SKILL.md index 1602b1ecd..4225cbe4e 100644 --- a/.agents/skills/write-docs/SKILL.md +++ b/.agents/skills/write-docs/SKILL.md @@ -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/.mdx` (or a plugin page), open `preview/pages/.astro` and add, as a direct child of ``: + +```astro +import DocsLink from '@ui/DocsLink.astro' +… + +``` + +- `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/.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. diff --git a/.changeset/sparkline.md b/.changeset/sparkline.md new file mode 100644 index 000000000..bbff25dd4 --- /dev/null +++ b/.changeset/sparkline.md @@ -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. diff --git a/core/js/src/sparkline.ts b/core/js/src/sparkline.ts new file mode 100644 index 000000000..aac218ce5 --- /dev/null +++ b/core/js/src/sparkline.ts @@ -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> & { + 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 = { + 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 = (tag: K): SVGElementTagNameMap[K] => document.createElementNS(SVG_NS, tag) + +const setAttr = (el: Element, attrs: Record): 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: + * + * + * + * 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 { + 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('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 diff --git a/core/js/tabler.ts b/core/js/tabler.ts index 1572eda42..9717511c6 100644 --- a/core/js/tabler.ts +++ b/core/js/tabler.ts @@ -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' diff --git a/core/js/tests/unit/sparkline.spec.ts b/core/js/tests/unit/sparkline.spec.ts new file mode 100644 index 000000000..386eede55 --- /dev/null +++ b/core/js/tests/unit/sparkline.spec.ts @@ -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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + new Sparkline(el()) + + expect(el().querySelector('line')).toBeNull() + }) + + it('should draw win, loss and tie bars for the tristate type', () => { + fixtureEl.innerHTML = '' + + 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 = '' + + 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 = '' + '' + + 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 = '' + + 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 = '' + '' + + 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 = '' + + 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 = '' + + new Sparkline(el()) + + expect(el().innerHTML).toBe('') + }) + + it('should accept values from the config object', () => { + fixtureEl.innerHTML = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + const sparkline = new Sparkline(el()) + sparkline.dispose() + + expect(el().innerHTML).toBe('') + expect(Sparkline.getInstance(el())).toBeNull() + }) + }) +}) diff --git a/core/scss/_core.scss b/core/scss/_core.scss index b4bf050ce..9111a85bd 100644 --- a/core/scss/_core.scss +++ b/core/scss/_core.scss @@ -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'; diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index 4b7caea3f..7e891e2fd 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -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; diff --git a/core/scss/ui/_sparkline.scss b/core/scss/ui/_sparkline.scss new file mode 100644 index 000000000..1b043d4be --- /dev/null +++ b/core/scss/ui/_sparkline.scss @@ -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}; +} diff --git a/docs/content/ui/components/sparkline.mdx b/docs/content/ui/components/sparkline.mdx new file mode 100644 index 000000000..6e6668a72 --- /dev/null +++ b/docs/content/ui/components/sparkline.mdx @@ -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. + + + + + +## 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`. + + + + + + + +## 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. + + + + + + +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. + + + + + + +## 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. + + + + + + +## 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. + + + + + + +## 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`. + + + + + + + + +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. + + + + + + + + +## 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. + + + + + + + + +## 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`. + + + + + + + +## 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. + + + + + + +## 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. + + + + + + + + + + + + + + + + + + + + + + +
MetricValueTrend
Revenue$12.4k
Errors31
+
+ +## 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: + + + +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: + + diff --git a/preview/pages/card-gradients.astro b/preview/pages/card-gradients.astro index 7526a58fb..847921a87 100644 --- a/preview/pages/card-gradients.astro +++ b/preview/pages/card-gradients.astro @@ -15,7 +15,7 @@ import { requireIndex } from '@shared/lib/array' import DocsLink from '@ui/DocsLink.astro' --- - +
diff --git a/preview/pages/sparkline.astro b/preview/pages/sparkline.astro new file mode 100644 index 000000000..29c5c3053 --- /dev/null +++ b/preview/pages/sparkline.astro @@ -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' }, +] +--- + + + +
+
+ + + Line + Add data-bs-toggle="sparkline" and a comma-separated data-bs-values list. + + + +
+ +
+ + + Area with spot + Use data-bs-fill="auto" for a tinted area and data-bs-spot to mark the last, min or max value. +
+ + + +
+
+
+
+ +
+ + + Bars + Set data-bs-type="bar". Tune the gap and rounding with data-bs-bar-gap and data-bs-bar-radius. +
+ + +
+
+
+
+ +
+ + + Signed bars + Negative values hang below a zero line in --tblr-sparkline-negative, red by default; the line uses --tblr-sparkline-zero. +
+ + +
+
+
+
+ +
+ + + Win/loss + Set data-bs-type="tristate": every value is a full bar up, down or a tick on the zero line, whatever its size. +
+ + +
+
+
+
+ +
+ + + Threshold + Set data-bs-threshold to draw a guide line at a value. Bars below it take the negative color. +
+ + +
+
+
+
+ +
+ + + Circle + Use data-bs-type="circle" with a single value as a share of data-bs-max, sparkline-square for a round box and data-bs-label="auto" for the percentage in the middle. +
+ {progress.map((item) => )} + {progress.map((item) => )} +
+
+
+
+ +
+ + + Disabled + Add sparkline-disabled to grey out a chart, for a series that is off or data that is stale. +
+ + + +
+
+
+
+ +
+ + + Colors + The chart uses currentColor, so any text-{'{color}'} utility themes it. +
+ {colors.map((color) => )} +
+
+
+
+ +
+ + + Fixed range + Set data-bs-min and data-bs-max so several charts share the same scale. +
+ + +
+
+
+
+ +
+ + + Sizes + Add sparkline-sm or sparkline-lg; for a custom box override --tblr-sparkline-width and --tblr-sparkline-height. +
+ + + +
+
+
+
+ +
+ + + Stroke width + Change --tblr-sparkline-stroke-width and --tblr-sparkline-spot-size in SVG units. +
+ + + +
+
+
+
+ +
+ + + Update from JavaScript + Call update(values) on the instance. It writes data-bs-values back, tweens the chart for data-bs-animation milliseconds and fires updated.bs.sparkline. +
+ + + +
+ + + + + +
+
+
+ +
+ + + In a table + A sparkline sits inline with text, so it works in table cells and stat rows. +
+ + + + + + + + + + { + metrics.map((metric) => ( + + + + + + )) + } + +
MetricValueTrend
{metric.name}{metric.value} + +
+
+
+
+
+
+
diff --git a/preview/pages/widgets.astro b/preview/pages/widgets.astro index 54edb018f..03671f227 100644 --- a/preview/pages/widgets.astro +++ b/preview/pages/widgets.astro @@ -14,7 +14,7 @@ import UserInfo from '@shared/components/cards/UserInfo.astro' import Configuration from '@shared/components/cards/Configuration.astro' --- - +
@@ -72,16 +72,16 @@ import Configuration from '@shared/components/cards/Configuration.astro'
- +
- +
- +
- +
@@ -102,9 +102,9 @@ import Configuration from '@shared/components/cards/Configuration.astro'
-
-
-
+
+
+
diff --git a/screenshots/pages/sparkline.astro b/screenshots/pages/sparkline.astro new file mode 100644 index 000000000..c449b2b52 --- /dev/null +++ b/screenshots/pages/sparkline.astro @@ -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' +--- + + +
+ { + charts.map((chart) => ( + + )) + } +
+
diff --git a/shared/components/cards/DevelopmentActivity.astro b/shared/components/cards/DevelopmentActivity.astro index 58671b6f3..c20442b79 100644 --- a/shared/components/cards/DevelopmentActivity.astro +++ b/shared/components/cards/DevelopmentActivity.astro @@ -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)
-
+
Today's Earning: $4,262.40
+5% more than yesterday
diff --git a/shared/components/cards/MostVisitedPages.astro b/shared/components/cards/MostVisitedPages.astro index 04710c440..9b136501a 100644 --- a/shared/components/cards/MostVisitedPages.astro +++ b/shared/components/cards/MostVisitedPages.astro @@ -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' {formatNumber(url.unique)} {url.bounce} - + )) diff --git a/shared/components/cards/SmallStats.astro b/shared/components/cards/SmallStats.astro index 528273c84..176adae0f 100644 --- a/shared/components/cards/SmallStats.astro +++ b/shared/components/cards/SmallStats.astro @@ -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
) : chartData && chartPosition === 'left' ? (
- +
) : null } @@ -63,7 +64,7 @@ const avatarClass = [color ? `bg-${color}${lt ? '-lt' : ' text-white'}` : '', 'a { chartData && chartPosition === 'right' && (
- +
) } diff --git a/shared/data/docs.json b/shared/data/docs.json index e23270d0a..413f6a384 100644 --- a/shared/data/docs.json +++ b/shared/data/docs.json @@ -264,6 +264,10 @@ "title": "Segmented control", "url": "/ui/components/segmented-control" }, + { + "title": "Sparkline", + "url": "/ui/components/sparkline" + }, { "title": "Spinner", "url": "/ui/components/spinner" diff --git a/shared/data/menu.json b/shared/data/menu.json index a5607f604..d6c8d734b 100644 --- a/shared/data/menu.json +++ b/shared/data/menu.json @@ -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" diff --git a/shared/ui/Sparkline.astro b/shared/ui/Sparkline.astro new file mode 100644 index 000000000..a64a0255d --- /dev/null +++ b/shared/ui/Sparkline.astro @@ -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] +--- + +