` 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..fe6e1a8
--- /dev/null
+++ b/src/components/stat-card/BqStatCard.ts
@@ -0,0 +1,209 @@
+/**
+ * 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 { getBaseStyles, srOnlyStyles } from '../../utils/styles.js';
+
+type BqStatCardProps = {
+ label: string;
+ value: string;
+ change: string;
+ hint: string;
+ trend: string;
+ size: string;
+ loading: boolean;
+};
+
+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 },
+ },
+ 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; }
+ }
+ `,
+ render({ props }) {
+ 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 describedBy = hint ? ' aria-describedby="stat-card-hint"' : '';
+ const labelledBy = label ? ' aria-labelledby="stat-card-label"' : '';
+ 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..214d335
--- /dev/null
+++ b/tests/stat-card.test.ts
@@ -0,0 +1,88 @@
+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 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 change = doc.createElement('bq-stat-card');
+ change.setAttribute('change', '-4.2%');
+ change.setAttribute('trend', 'warning');
+ doc.body.appendChild(change);
+
+ expect(card?.getAttribute('data-size')).toBe('md');
+ expect(change.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('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"]');
+
+ expect(card?.getAttribute('aria-busy')).toBe('true');
+ expect(loading).not.toBeNull();
+ expect(status?.textContent).toBe('Loading');
+ });
+});
From ced32e9029b8df65cd60c523bc3a2ba3c9b3605a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 24 Mar 2026 15:10:45 +0000
Subject: [PATCH 2/4] test: clarify stat card coverage naming
Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com>
Agent-Logs-Url: https://github.com/bQuery/ui/sessions/01360682-98d4-484f-a913-88f2d137cdee
---
tests/stat-card.test.ts | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/tests/stat-card.test.ts b/tests/stat-card.test.ts
index 214d335..19c82ec 100644
--- a/tests/stat-card.test.ts
+++ b/tests/stat-card.test.ts
@@ -53,13 +53,13 @@ describe('BqStatCard', () => {
doc.body.appendChild(el);
const card = el.shadowRoot?.querySelector('[part="card"]');
- const change = doc.createElement('bq-stat-card');
- change.setAttribute('change', '-4.2%');
- change.setAttribute('trend', 'warning');
- doc.body.appendChild(change);
+ 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(change.shadowRoot?.querySelector('[part="change"]')?.getAttribute('data-trend')).toBe('neutral');
+ expect(changeEl.shadowRoot?.querySelector('[part="change"]')?.getAttribute('data-trend')).toBe('neutral');
});
it('should support compact sizing', () => {
From fc103f44676908da283a014b6666c2903fca9cd5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 24 Mar 2026 15:23:27 +0000
Subject: [PATCH 3/4] fix: tighten stat card aria id wiring
Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com>
Agent-Logs-Url: https://github.com/bQuery/ui/sessions/e73fbf15-2dc3-48ce-92fe-341ccd3a8032
---
src/components/stat-card/BqStatCard.ts | 40 ++++++++++++++++++++------
tests/stat-card.test.ts | 29 +++++++++++++++++++
2 files changed, 61 insertions(+), 8 deletions(-)
diff --git a/src/components/stat-card/BqStatCard.ts b/src/components/stat-card/BqStatCard.ts
index fe6e1a8..dc7848b 100644
--- a/src/components/stat-card/BqStatCard.ts
+++ b/src/components/stat-card/BqStatCard.ts
@@ -15,6 +15,7 @@ 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 = {
@@ -26,6 +27,7 @@ type BqStatCardProps = {
size: string;
loading: boolean;
};
+type BqStatCardState = { uid: string };
function getTrend(trend: string): 'up' | 'down' | 'neutral' {
if (trend === 'up' || trend === 'down') return trend;
@@ -36,7 +38,7 @@ function getSize(size: string): 'sm' | 'md' {
return size === 'sm' ? 'sm' : 'md';
}
-const definition: ComponentDefinition = {
+const definition: ComponentDefinition = {
props: {
label: { type: String, default: '' },
value: { type: String, default: '' },
@@ -46,6 +48,9 @@ const definition: ComponentDefinition = {
size: { type: String, default: 'md' },
loading: { type: Boolean, default: false },
},
+ state: {
+ uid: '',
+ },
styles: `
${getBaseStyles()}
${srOnlyStyles}
@@ -165,15 +170,34 @@ const definition: ComponentDefinition = {
.skeleton::after { animation: none; }
}
`,
- render({ props }) {
+ 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 describedBy = hint ? ' aria-describedby="stat-card-hint"' : '';
- const labelledBy = label ? ' aria-labelledby="stat-card-label"' : '';
+ 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
@@ -182,7 +206,7 @@ const definition: ComponentDefinition = {
- ${escapeHtml(t('common.loading'))}
+ ${escapeHtml(t('common.loading'))}
`
: `
@@ -190,14 +214,14 @@ const definition: ComponentDefinition = {
${value ? `${escapeHtml(value)}
` : ''}
${change ? `${escapeHtml(change)}` : ''}
- ${hint ? `${escapeHtml(hint)}
` : ''}
+ ${hint ? `${escapeHtml(hint)}
` : ''}
`;
return html`
${body}
@@ -206,4 +230,4 @@ const definition: ComponentDefinition = {
},
};
-component('bq-stat-card', definition);
+component('bq-stat-card', definition);
diff --git a/tests/stat-card.test.ts b/tests/stat-card.test.ts
index 19c82ec..4cefa19 100644
--- a/tests/stat-card.test.ts
+++ b/tests/stat-card.test.ts
@@ -46,6 +46,31 @@ describe('BqStatCard', () => {
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');
@@ -74,15 +99,19 @@ describe('BqStatCard', () => {
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(card?.getAttribute('aria-describedby')).toBe(status?.id);
+ expect(hint).toBeNull();
});
});
From 8c3ddd1be91f34127c53c5c7e0f7c9e8918cf83e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 24 Mar 2026 15:36:43 +0000
Subject: [PATCH 4/4] fix: add stat card live region hint
Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com>
Agent-Logs-Url: https://github.com/bQuery/ui/sessions/12a3900c-2656-4eda-b7cb-028de4fa9f9e
---
src/components/stat-card/BqStatCard.ts | 2 +-
tests/stat-card.test.ts | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/components/stat-card/BqStatCard.ts b/src/components/stat-card/BqStatCard.ts
index dc7848b..94d16f9 100644
--- a/src/components/stat-card/BqStatCard.ts
+++ b/src/components/stat-card/BqStatCard.ts
@@ -206,7 +206,7 @@ const definition: ComponentDefinition = {
- ${escapeHtml(t('common.loading'))}
+ ${escapeHtml(t('common.loading'))}
`
: `
diff --git a/tests/stat-card.test.ts b/tests/stat-card.test.ts
index 4cefa19..489314d 100644
--- a/tests/stat-card.test.ts
+++ b/tests/stat-card.test.ts
@@ -111,6 +111,7 @@ describe('BqStatCard', () => {
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();
});