From 1bec7d8622aba46e0fc3d9adcecf779e87a29404 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:09:51 +0000 Subject: [PATCH 1/4] feat: add stat card dashboard component Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> Agent-Logs-Url: https://github.com/bQuery/ui/sessions/01360682-98d4-484f-a913-88f2d137cdee --- README.md | 2 +- docs/.vitepress/config.ts | 1 + docs/components/index.md | 3 +- docs/components/stat-card.md | 122 +++++++++++++++ docs/guide/architecture-roadmap.md | 16 ++ src/components/index.ts | 1 + src/components/stat-card/BqStatCard.ts | 209 +++++++++++++++++++++++++ src/components/stat-card/index.ts | 1 + src/index.ts | 2 + stories/stat-card.stories.ts | 51 ++++++ tests/stat-card.test.ts | 88 +++++++++++ 11 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 docs/components/stat-card.md create mode 100644 src/components/stat-card/BqStatCard.ts create mode 100644 src/components/stat-card/index.ts create mode 100644 stories/stat-card.stories.ts create mode 100644 tests/stat-card.test.ts 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..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` +
+
+ ${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..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`
- ${label ? `

${escapeHtml(label)}

` : ''} + ${label ? `

${escapeHtml(label)}

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