diff --git a/README.md b/README.md index 74f4d20..159dc4d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The current library covers the core component categories developers expect from | **Actions** | Button, Icon Button | | **Forms** | Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Chip | | **Navigation** | Tabs, Accordion, Breadcrumbs, Pagination | -| **Data Display** | Card, Badge, Avatar, Table, Divider, Empty State | +| **Data Display** | Card, Badge, Avatar, Table, Divider, Empty State, Stat Card | | **Feedback** | Alert, Progress, Spinner, Skeleton, Tooltip, Toast | | **Overlays** | Dialog, Drawer | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6467786..b1ef7d9 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -53,6 +53,7 @@ export default defineConfig({ { text: 'Skeleton', link: '/components/skeleton' }, { text: 'Slider', link: '/components/slider' }, { text: 'Spinner', link: '/components/spinner' }, + { text: 'Stat Card', link: '/components/stat-card' }, { text: 'Switch', link: '/components/switch' }, { text: 'Table', link: '/components/table' }, { text: 'Tabs', link: '/components/tabs' }, diff --git a/docs/components/index.md b/docs/components/index.md index 805cd3c..416b976 100644 --- a/docs/components/index.md +++ b/docs/components/index.md @@ -11,7 +11,7 @@ This catalog gives you a complete overview of the currently available building b | **Actions** | `bq-button`, `bq-icon-button` | | **Forms** | `bq-input`, `bq-textarea`, `bq-select`, `bq-segmented-control`, `bq-checkbox`, `bq-radio`, `bq-switch`, `bq-slider`, `bq-chip` | | **Navigation & Disclosure** | `bq-tabs`, `bq-accordion`, `bq-breadcrumbs`, `bq-pagination` | -| **Data Display** | `bq-card`, `bq-badge`, `bq-avatar`, `bq-table`, `bq-divider`, `bq-empty-state` | +| **Data Display** | `bq-card`, `bq-badge`, `bq-avatar`, `bq-table`, `bq-divider`, `bq-empty-state`, `bq-stat-card` | | **Feedback & Loading** | `bq-alert`, `bq-progress`, `bq-spinner`, `bq-skeleton`, `bq-tooltip`, `bq-toast` | | **Overlays** | `bq-dialog`, `bq-drawer`, `bq-dropdown-menu` | @@ -66,6 +66,7 @@ Across the component library you will find the same professional capabilities th | `bq-table` | Tabular datasets | Sortable columns, bordered/striped/hoverable variants, empty/loading states | | `bq-divider` | Visual separation of content | Horizontal/vertical orientation, label support, multiple line styles | | `bq-empty-state` | No-data or no-results messaging | Title, description, icon slot, action slot | +| `bq-stat-card` | Dashboard metrics and KPI summaries | Compact metric layout, trend pill, loading skeleton, icon slot | ## Feedback & Loading diff --git a/docs/components/stat-card.md b/docs/components/stat-card.md new file mode 100644 index 0000000..b3bb022 --- /dev/null +++ b/docs/components/stat-card.md @@ -0,0 +1,122 @@ +# Stat Card + +The `bq-stat-card` component is a compact dashboard primitive for KPI summaries, analytics overviews, and settings surfaces that need a quick metric + context pairing. + +It is intentionally lightweight: use it when you need a polished, theme-aware metric card without introducing a full charting or dashboard framework. + +## Import + +```ts +import '@bquery/ui/components/stat-card'; +``` + +## Basic Usage + +```html + +``` + +## With Icon Slot + +```html + + + +``` + +## Sizes + +Use `size="sm"` when the card needs to fit tighter dashboards, narrow mobile layouts, or dense admin surfaces. + +```html + + +``` + +## Loading State + +When data is still loading, the component can render an internal skeleton layout while preserving accessible status text. + +```html + +``` + +## Additional Supporting Content + +The default slot can render extra supporting content such as inline badges, notes, or actions below the hint text. + +```html + + Healthy + +``` + +## Properties + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `label` | `string` | `''` | Short metric label shown at the top of the card. | +| `value` | `string` | `''` | Primary value, such as a count, percentage, or formatted currency amount. | +| `change` | `string` | `''` | Secondary comparison value, such as `+12%` or `-4`. | +| `hint` | `string` | `''` | Supporting explanatory text shown below the value row. | +| `trend` | `up \| down \| neutral` | `neutral` | Visual tone for the `change` pill. Invalid values fall back to `neutral`. | +| `size` | `sm \| md` | `md` | Controls spacing and value sizing. Invalid values fall back to `md`. | +| `loading` | `boolean` | `false` | Displays an internal loading skeleton and sets `aria-busy="true"`. | + +## Slots + +| Slot | Description | +| --- | --- | +| *(default)* | Additional supporting content rendered below the hint text. | +| `icon` | Optional icon, avatar, or badge rendered in the top-right corner. | + +## CSS Parts + +| Part | Description | +| --- | --- | +| `card` | Outer metric surface | +| `header` | Top row containing the label and icon slot | +| `label` | Metric label | +| `value` | Primary metric value | +| `change` | Trend/change pill | +| `hint` | Supporting description | +| `loading` | Internal loading layout wrapper | + +## Accessibility Notes + +- The outer surface uses semantic `
` markup. +- `label` is connected to the card with `aria-labelledby`, and `hint` is connected with `aria-describedby`. +- `loading` sets `aria-busy="true"` and exposes a localized screen-reader status message. +- Keep the `change` text meaningful on its own. For example, prefer `+12.4%` or `12 fewer incidents` over a color-only status. + +## Localization Notes + +- Keep `label`, `value`, `change`, and `hint` fully localizable in the host application. +- The built-in loading announcement uses the shared library locale, so custom locales continue to work without extra wiring. +- Use locale-aware formatting for numbers, currencies, and percentages before passing the resulting strings to the component. + +## Theming Notes + +- The component uses shared tokens for background, border, text, success/danger emphasis, spacing, radius, and shadow. +- Override host-level tokens for brand alignment, or target internal elements with `::part(card)`, `::part(value)`, and `::part(change)` for more specific customization. diff --git a/docs/guide/architecture-roadmap.md b/docs/guide/architecture-roadmap.md index 21a456e..3928d26 100644 --- a/docs/guide/architecture-roadmap.md +++ b/docs/guide/architecture-roadmap.md @@ -40,6 +40,7 @@ Reviewing mature systems such as Radix UI, shadcn/ui, Chakra UI, Mantine, Materi - Better **keyboard parity** across all navigation and overlay components - More **documentation pages for architecture, roadmap, theming, and usage patterns** - Continued refinement of **mobile-friendly sizing, focus behavior, and RTL-aware navigation** +- More **dashboard and summary primitives** that help product teams build real application surfaces instead of only low-level controls ### Advanced future opportunities @@ -94,9 +95,24 @@ This batch improves the library in a way that mirrors mature UI ecosystems witho - strengthens an existing **overlay/navigation component**, - and improves documentation so the library feels more like an intentional platform than a loose collection of widgets. +## Latest High-Value Batch + +This follow-up batch focuses on a missing dashboard primitive that appears consistently across mature design systems and admin-oriented UI libraries: + +1. **New `bq-stat-card` component** + - purpose-built for KPI summaries, health metrics, and compact dashboard cards, + - supports loading state, trend styling, icon composition, and mobile-friendly compact sizing, + - stays aligned with the existing token system and localization strategy. + +2. **Documentation updates** + - new `bq-stat-card` reference page, + - updated component catalog and sidebar coverage, + - refreshed roadmap language to call out dashboard/data-summary primitives as a current priority. + ## Recommended Next Steps - Add a composable **combobox / rich select** next. - Expand **table recipes** and column-control patterns. - Add more **layout and workspace primitives** for responsive app shells and settings pages. - Continue auditing older components for shared size/variant/state consistency. +- Build on `bq-stat-card` with companion dashboard patterns such as activity feeds, metric groups, and master-detail analytics layouts. diff --git a/src/components/index.ts b/src/components/index.ts index 88a7289..8f64d0e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -25,6 +25,7 @@ import './segmented-control/BqSegmentedControl.js'; import './skeleton/BqSkeleton.js'; import './slider/BqSlider.js'; import './spinner/BqSpinner.js'; +import './stat-card/BqStatCard.js'; import './switch/BqSwitch.js'; import './table/BqTable.js'; import './tabs/BqTabs.js'; diff --git a/src/components/stat-card/BqStatCard.ts b/src/components/stat-card/BqStatCard.ts new file mode 100644 index 0000000..94d16f9 --- /dev/null +++ b/src/components/stat-card/BqStatCard.ts @@ -0,0 +1,233 @@ +/** + * Stat card component - compact metric surface for dashboards and summaries. + * @element bq-stat-card + * @prop {string} label - Metric label + * @prop {string} value - Primary metric value + * @prop {string} change - Secondary delta or comparison value + * @prop {string} hint - Supporting description + * @prop {string} trend - up | down | neutral + * @prop {string} size - sm | md + * @prop {boolean} loading - Displays a loading skeleton while data is pending + * @slot - Additional supporting content rendered below the hint + * @slot icon - Optional icon or badge for the metric + */ +import { component, html } from '@bquery/bquery/component'; +import type { ComponentDefinition } from '@bquery/bquery/component'; +import { escapeHtml } from '@bquery/bquery/security'; +import { t } from '../../i18n/index.js'; +import { uniqueId } from '../../utils/dom.js'; +import { getBaseStyles, srOnlyStyles } from '../../utils/styles.js'; + +type BqStatCardProps = { + label: string; + value: string; + change: string; + hint: string; + trend: string; + size: string; + loading: boolean; +}; +type BqStatCardState = { uid: string }; + +function getTrend(trend: string): 'up' | 'down' | 'neutral' { + if (trend === 'up' || trend === 'down') return trend; + return 'neutral'; +} + +function getSize(size: string): 'sm' | 'md' { + return size === 'sm' ? 'sm' : 'md'; +} + +const definition: ComponentDefinition = { + props: { + label: { type: String, default: '' }, + value: { type: String, default: '' }, + change: { type: String, default: '' }, + hint: { type: String, default: '' }, + trend: { type: String, default: 'neutral' }, + size: { type: String, default: 'md' }, + loading: { type: Boolean, default: false }, + }, + state: { + uid: '', + }, + styles: ` + ${getBaseStyles()} + ${srOnlyStyles} + *, *::before, *::after { box-sizing: border-box; } + :host { display: block; } + .card { + display: grid; + gap: var(--bq-space-4,1rem); + min-height: 100%; + padding: var(--bq-space-6,1.5rem); + border-radius: var(--bq-radius-xl,0.75rem); + border: 1px solid var(--bq-border-base,#e2e8f0); + background: var(--bq-bg-base,#fff); + box-shadow: var(--bq-shadow-sm); + color: var(--bq-text-base,#0f172a); + font-family: var(--bq-font-family-sans); + } + .card[data-size="sm"] { + gap: var(--bq-space-3,0.75rem); + padding: var(--bq-space-4,1rem); + } + .header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--bq-space-3,0.75rem); + } + .label { + margin: 0; + font-size: var(--bq-font-size-sm,0.875rem); + font-weight: var(--bq-font-weight-medium,500); + color: var(--bq-text-muted,#475569); + line-height: 1.4; + } + .icon-slot { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--bq-color-primary-600,#2563eb); + min-width: 0; + flex-shrink: 0; + } + .icon-slot slot[name="icon"]::slotted(*) { + max-width: 100%; + } + .value-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--bq-space-3,0.75rem); + flex-wrap: wrap; + } + .value { + margin: 0; + font-size: clamp(1.75rem, 4vw, 2.25rem); + font-weight: var(--bq-font-weight-bold,700); + line-height: 1.1; + letter-spacing: -0.02em; + color: var(--bq-text-base,#0f172a); + } + .card[data-size="sm"] .value { + font-size: clamp(1.375rem, 3.5vw, 1.875rem); + } + .change { + display: inline-flex; + align-items: center; + gap: 0.375rem; + min-height: 2rem; + padding: 0.25rem 0.625rem; + border-radius: var(--bq-radius-full,9999px); + font-size: var(--bq-font-size-sm,0.875rem); + font-weight: var(--bq-font-weight-semibold,600); + white-space: nowrap; + background: var(--bq-bg-subtle,#f8fafc); + color: var(--bq-text-muted,#475569); + } + .change[data-trend="up"] { + background: color-mix(in srgb, var(--bq-color-success-500,#22c55e) 14%, transparent); + color: var(--bq-color-success-700,#15803d); + } + .change[data-trend="down"] { + background: color-mix(in srgb, var(--bq-color-danger-500,#ef4444) 14%, transparent); + color: var(--bq-color-danger-700,#b91c1c); + } + .hint { + margin: 0; + font-size: var(--bq-font-size-sm,0.875rem); + line-height: 1.5; + color: var(--bq-text-muted,#475569); + } + .loading-layout { + display: grid; + gap: var(--bq-space-3,0.75rem); + } + .skeleton { + position: relative; + overflow: hidden; + display: block; + border-radius: var(--bq-radius-md,0.375rem); + background: var(--bq-bg-subtle,#e2e8f0); + } + .skeleton::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.45), transparent); + animation: stat-card-shimmer 1.5s infinite; + } + .skeleton-label { width: 40%; height: 0.875rem; } + .skeleton-value { width: 55%; height: 2.25rem; } + .skeleton-hint { width: 75%; height: 0.875rem; } + @keyframes stat-card-shimmer { + 100% { transform: translateX(100%); } + } + @media (prefers-reduced-motion: reduce) { + .skeleton::after { animation: none; } + } + `, + connected() { + type BqStatCardElement = HTMLElement & { + setState(k: 'uid', v: string): void; + getState(k: string): T; + }; + const self = this as unknown as BqStatCardElement; + if (!self.getState('uid')) self.setState('uid', uniqueId('bq-stat-card')); + }, + render({ props, state }) { + const label = props.label.trim(); + const value = props.value.trim(); + const change = props.change.trim(); + const hint = props.hint.trim(); + const trend = getTrend(props.trend); + const size = getSize(props.size); + const uid = state.uid || 'bq-stat-card'; + const labelId = `${uid}-label`; + const hintId = `${uid}-hint`; + const statusId = `${uid}-status`; + const describedByIds = props.loading + ? statusId + : hint + ? hintId + : ''; + const describedBy = describedByIds + ? ` aria-describedby="${escapeHtml(describedByIds)}"` + : ''; + const labelledBy = label ? ` aria-labelledby="${escapeHtml(labelId)}"` : ''; + const busy = props.loading ? ' aria-busy="true"' : ''; + + const body = props.loading + ? ` +
+ + + + ${escapeHtml(t('common.loading'))} +
+ ` + : ` +
+ ${value ? `

${escapeHtml(value)}

` : ''} + ${change ? `${escapeHtml(change)}` : ''} +
+ ${hint ? `

${escapeHtml(hint)}

` : ''} + + `; + + return html` +
+
+ ${label ? `

${escapeHtml(label)}

` : ''} +
+
+ ${body} +
+ `; + }, +}; + +component('bq-stat-card', definition); diff --git a/src/components/stat-card/index.ts b/src/components/stat-card/index.ts new file mode 100644 index 0000000..b63a671 --- /dev/null +++ b/src/components/stat-card/index.ts @@ -0,0 +1 @@ +export * as __bqComponentEntry from './BqStatCard.js'; diff --git a/src/index.ts b/src/index.ts index d2bab1a..19071d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,7 @@ import { __bqComponentEntry as segmentedControlComponentEntry } from './componen import { __bqComponentEntry as skeletonComponentEntry } from './components/skeleton/index.js'; import { __bqComponentEntry as sliderComponentEntry } from './components/slider/index.js'; import { __bqComponentEntry as spinnerComponentEntry } from './components/spinner/index.js'; +import { __bqComponentEntry as statCardComponentEntry } from './components/stat-card/index.js'; import { __bqComponentEntry as switchComponentEntry } from './components/switch/index.js'; import { __bqComponentEntry as tableComponentEntry } from './components/table/index.js'; import { __bqComponentEntry as tabsComponentEntry } from './components/tabs/index.js'; @@ -81,6 +82,7 @@ Object.defineProperty(registerAll, COMPONENT_ENTRY_MAP_KEY, { 'skeleton': skeletonComponentEntry, 'slider': sliderComponentEntry, 'spinner': spinnerComponentEntry, + 'stat-card': statCardComponentEntry, 'switch': switchComponentEntry, 'table': tableComponentEntry, 'tabs': tabsComponentEntry, diff --git a/stories/stat-card.stories.ts b/stories/stat-card.stories.ts new file mode 100644 index 0000000..e88b68c --- /dev/null +++ b/stories/stat-card.stories.ts @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; +import { storyHtml } from '@bquery/bquery/storybook'; + +const meta: Meta = { + title: 'Data Display/Stat Card', + tags: ['autodocs'], + render: (args) => storyHtml` + + + ${args.extra ? `${args.extra}` : ''} + + `, + argTypes: { + label: { control: 'text' }, + value: { control: 'text' }, + change: { control: 'text' }, + hint: { control: 'text' }, + trend: { control: 'select', options: ['up', 'down', 'neutral'] }, + size: { control: 'select', options: ['sm', 'md'] }, + loading: { control: 'boolean' }, + icon: { control: 'text' }, + extra: { control: 'text' }, + }, + args: { + label: 'Monthly revenue', + value: '$128k', + change: '+12.4%', + hint: 'Compared with the previous 30 days.', + trend: 'up', + size: 'md', + loading: false, + icon: '📈', + extra: 'Healthy', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const Compact: Story = { args: { size: 'sm', icon: '⚡', extra: 'Live' } }; +export const Loading: Story = { args: { loading: true, value: '', change: '', extra: '' } }; diff --git a/tests/stat-card.test.ts b/tests/stat-card.test.ts new file mode 100644 index 0000000..489314d --- /dev/null +++ b/tests/stat-card.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeAll, describe, expect, it } from 'bun:test'; +import { existsSync } from 'node:fs'; + +const win = (globalThis as unknown as Record)['window'] as Window & typeof globalThis; +const doc = win.document as unknown as Document; + +describe('BqStatCard', () => { + beforeAll(async () => { + await import('../src/components/stat-card/index.js'); + }); + + afterEach(() => { + doc.body.innerHTML = ''; + }); + + it('should define bq-stat-card as a custom element', () => { + expect(win.customElements.get('bq-stat-card')).toBeDefined(); + }); + + it('should expose only the stat card wrapper export from the entrypoint', async () => { + const componentModuleUrl = new URL('../src/components/stat-card/BqStatCard.ts', import.meta.url); + const entrypointModule = await import('../src/components/stat-card/index.js'); + + expect(existsSync(componentModuleUrl)).toBe(true); + expect(Object.keys(entrypointModule)).toEqual(['__bqComponentEntry']); + }); + + it('should render label, value, change, and hint content', () => { + const el = doc.createElement('bq-stat-card'); + el.setAttribute('label', 'Monthly revenue'); + el.setAttribute('value', '$128k'); + el.setAttribute('change', '+12.4%'); + el.setAttribute('hint', 'Compared with the previous 30 days.'); + el.setAttribute('trend', 'up'); + doc.body.appendChild(el); + + const label = el.shadowRoot?.querySelector('[part="label"]'); + const value = el.shadowRoot?.querySelector('[part="value"]'); + const change = el.shadowRoot?.querySelector('[part="change"]'); + const hint = el.shadowRoot?.querySelector('[part="hint"]'); + + expect(label?.textContent).toBe('Monthly revenue'); + expect(value?.textContent).toBe('$128k'); + expect(change?.textContent).toBe('+12.4%'); + expect(change?.getAttribute('data-trend')).toBe('up'); + expect(hint?.textContent).toBe('Compared with the previous 30 days.'); + }); + + it('should generate unique accessible ids per instance', () => { + const first = doc.createElement('bq-stat-card'); + first.setAttribute('label', 'Monthly revenue'); + first.setAttribute('hint', 'Compared with the previous 30 days.'); + const second = doc.createElement('bq-stat-card'); + second.setAttribute('label', 'Incident resolution'); + second.setAttribute('hint', 'SLA within the current quarter.'); + + doc.body.append(first, second); + + const firstCard = first.shadowRoot?.querySelector('[part="card"]'); + const secondCard = second.shadowRoot?.querySelector('[part="card"]'); + const firstLabel = first.shadowRoot?.querySelector('[part="label"]'); + const secondLabel = second.shadowRoot?.querySelector('[part="label"]'); + const firstHint = first.shadowRoot?.querySelector('[part="hint"]'); + const secondHint = second.shadowRoot?.querySelector('[part="hint"]'); + + expect(firstLabel?.id).not.toBe(secondLabel?.id); + expect(firstHint?.id).not.toBe(secondHint?.id); + expect(firstCard?.getAttribute('aria-labelledby')).toBe(firstLabel?.id); + expect(secondCard?.getAttribute('aria-labelledby')).toBe(secondLabel?.id); + expect(firstCard?.getAttribute('aria-describedby')).toBe(firstHint?.id); + expect(secondCard?.getAttribute('aria-describedby')).toBe(secondHint?.id); + }); + + it('should normalize invalid size and trend values', () => { + const el = doc.createElement('bq-stat-card'); + el.setAttribute('size', 'xl'); + el.setAttribute('trend', 'warning'); + doc.body.appendChild(el); + + const card = el.shadowRoot?.querySelector('[part="card"]'); + const changeEl = doc.createElement('bq-stat-card'); + changeEl.setAttribute('change', '-4.2%'); + changeEl.setAttribute('trend', 'warning'); + doc.body.appendChild(changeEl); + + expect(card?.getAttribute('data-size')).toBe('md'); + expect(changeEl.shadowRoot?.querySelector('[part="change"]')?.getAttribute('data-trend')).toBe('neutral'); + }); + + it('should support compact sizing', () => { + const el = doc.createElement('bq-stat-card'); + el.setAttribute('size', 'sm'); + doc.body.appendChild(el); + + const card = el.shadowRoot?.querySelector('[part="card"]'); + expect(card?.getAttribute('data-size')).toBe('sm'); + }); + + it('should expose a loading state for assistive technology', () => { + const el = doc.createElement('bq-stat-card'); + el.setAttribute('label', 'Active users'); + el.setAttribute('hint', 'This hint should not be referenced while loading.'); + el.setAttribute('loading', ''); + doc.body.appendChild(el); + + const card = el.shadowRoot?.querySelector('[part="card"]'); + const loading = el.shadowRoot?.querySelector('[part="loading"]'); + const status = el.shadowRoot?.querySelector('[role="status"]'); + const hint = el.shadowRoot?.querySelector('[part="hint"]'); + + expect(card?.getAttribute('aria-busy')).toBe('true'); + expect(loading).not.toBeNull(); + expect(status?.textContent).toBe('Loading'); + expect(status?.getAttribute('aria-live')).toBe('polite'); + expect(card?.getAttribute('aria-describedby')).toBe(status?.id); + expect(hint).toBeNull(); + }); +});