('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`
+
+
+ ${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.icon}
+ ${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();
+ });
+});