020 / RECHARTS / BAR & COLUMN

Group Compare Bars

Recharts

Side-by-side peer measures within stable categorical groups, with shared quantitative scaling, deterministic series identity, and category-centric inspection.

SPEC
#020
ENGINE
Recharts
FAMILY
Bar & Column
RENDERER
svg
STATUS
preview

Installation

PLOTCN/REGISTRY/BAR-GROUP-COMPARE/SOURCE
pnpm dlx shadcn@latest add @plotcn/bar-group-compare

Checking public registry…

View local registry JSON
REGISTRY direct URL·ENGINE Recharts·FILES 1·DEPENDENCIES 5

Copied as source into your project (requires recharts).

× 340Container width
Measuring preview...
RECHARTS · SVG · 0 × 340pxMotion enabled · ResizeObserver
Group Compare Bars compares peer quantitative measures side-by-side inside a shared categorical band. Category order and series identity remain stable, every visible series shares one quantitative scale, and missing values must never be disguised as zero-height bars.
TEXT
021 / RECHARTS / BAR · SVG · GROUPED · MULTI-SERIES · RESPONSIVE · SOURCE-FIRST

Quick Facts

Property Value Notes
PropertyValueNotes
EngineRechartsCartesian coordinates via <BarChart>, <Bar>, <XAxis>, <YAxis>
RendererSVGCrisp, scalable vector graphics across all DPRs
CompositionGrouped (Side-by-Side)Discrete peer slots within each categorical band
Series Contract2+ peer measuresOrdered peer series sharing identical quantitative unit and scale
Category OrderSource orderStrictly preserved from caller data without auto-sorting
Series OrderConfiguration orderStrictly preserved; never reorders by value magnitude
Quantitative ScaleShared zero-anchoredSingle Y-axis scale (or X-axis in horizontal); equal values yield equal lengths
BaselineZero (0)Grounded reference line; positive bars extend up, negative extend down
Missing PolicySlot reservedMissing data renders no bar; slot remains reserved (no impersonation)
Zero PolicyTruthful observationValue 0 renders 0-height bar; tooltip displays "0"
LegendVisible by defaultCanonical order; interactive visibility toggling supported
InspectionCategory-centricBroad category band activation + precise bar rectangle focus
AccessibilityDual-tier1 tab stop, ArrowLeft/Right (or Up/Down), live region, offscreen HTML <table>

Installation

Install the component directly into your project using the shadcn CLI:

Terminal
npx shadcn@latest add @plotcn/bar-group-compare

Dependencies

JSON
{  "dependencies": {    "recharts": "^2.15.0"  },  "registryDependencies": [    "@plotcn/chart-container",    "@plotcn/chart-state",    "@plotcn/chart-tooltip",    "@plotcn/chart-motion"  ]}

Usage

TSX
import { GroupCompareBars } from "@/components/charts/recharts/bar-group-compare"const quarterlyRevenue = [  { quarter: "Q1", current: 184000, previous: 163000 },  { quarter: "Q2", current: 216000, previous: 191000 },  { quarter: "Q3", current: 228000, previous: 207000 },  { quarter: "Q4", current: 252000, previous: 236000 },]export function RevenueComparisonChart() {  return (    <GroupCompareBars      data={quarterlyRevenue}      categoryKey="quarter"      series={[        { key: "current", label: "Current Year", color: "var(--chart-1)" },        { key: "previous", label: "Previous Year", color: "var(--chart-2)" },      ]}      valueFormatter={(v) => `$${(v / 1000).toFixed(0)}k`}    />  )}

Comparison Model

In Group Compare Bars, each discrete category owns a shared comparison band. Inside that band, every configured peer series occupies a stable slot and extends independently from the common zero baseline.

Group Compare Bars Comparison ModelEach category contains stable side-by-side slots for peer series measured against one shared quantitative scale and zero baseline.6004002000Quarter 1 (Q1)Category Band420Web510MobileQuarter 2 (Q2)Category Band480Web570MobilegroupGapbarGap

Key Analytical Principles

  1. Peer Relationship: Series are treated as ordered peers rather than primary vs. reference or part-to-whole contributors.
  2. Independent Magnitudes: Each bar measures strictly from baseline zero. A grouped chart never sums values across series.
  3. Band Cohesion: Neighboring groups are separated by groupGap, while peer bars inside one group are separated by a subtle barGap.

Grouped vs. Stacked Semantics

The analytical difference between Group Compare Bars (021) and Stack Ledger Bars (022) is fundamental and must never be conflated behind a toggle:

Grouped Compare vs Stacked CompositionContrasting independent side-by-side peer bars starting from zero versus additive stacked bars composing a cumulative total.GROUPED BARS (021)Peer comparison • Independent magnitudes from zeroZero Baseline (0)120Current100PreviousQ1Current = 120Previous = 100No sum impliedSTACKED BARS (022)Additive composition • Segments sum to a totalZero Baseline (0)120100Total = 220Q1120 + 100 = 220Additive ledgerDifferent question
  • Grouped Bars (021) answer: "Within each category, how do multiple comparable measures differ from one another?" Bars sit side-by-side; each represents an independent quantity.
  • Stacked Bars (022) answer: "What is the total magnitude for each category, and how do additive contributors compose that total?" Segments visually join into one cumulative whole.

Because GroupCompareBars has one dedicated analytical identity, generic stackMode props have been specialized away.

Stable Series Identity

A core invariant of Plotcn visualization is spatial predictability. Regardless of how values vary or cross between categories, series positions within the group never change.

Stable Series Identity Across CategoriesShows that series order [Web, Mobile, Partner] remains invariant across all categories, even when magnitude crosses.Configured Canonical Order:1. Web2. Mobile3. PartnerQ1: Web LeadingWeb > Mobile > Partner906030Q2: Mobile LeadingSlot order UNCHANGED409570Q3: Partner LeadingSlot order UNCHANGED6050100

Why Dynamic Sorting Is Forbidden

If Series A is higher than Series B in Q1, but Series B is higher than Series A in Q2, dynamically sorting bars inside each group would cause spatial positions to swap:

TEXT
// FORBIDDEN: Unstable spatial swappingQ1: [ Slot 1: A (80) ]  [ Slot 2: B (60) ]Q2: [ Slot 1: B (95) ]  [ Slot 2: A (40) ]  // Swapped! Cognitive dissonance

In GroupCompareBars, Slot 1 is always Series A and Slot 2 is always Series B across all categories. The user's spatial memory is preserved.

Missing-Series Slot Reservation

Real-world datasets frequently contain unrecorded or missing data for one series in a given period.

Missing Series Slot ReservationMissing data reserves the slot rather than drawing a fake zero bar or allowing surviving series to shift left.0 BaselineQ1: CompleteAll 3 series present80A65B50CQ2: Series B MissingSlot reserved • No impersonation90AReservedSlot B60CQ3: CompleteAlignment maintained75A85B40C

The Slot Reservation Rule

When a series value is null, undefined, or non-finite:

  1. No Fake Bar: No rectangle of 0 height is fabricated.
  2. Slot Reserved: The spatial slot for that series remains reserved.
  3. No Impersonation: Neighboring surviving series do not shift left into the empty slot. Series C will never render in Slot B's position.
  4. Explicit Tooltip: The tooltip explicitly labels the metric as "Unavailable" rather than hiding the row or falsely asserting zero.

Shared Quantitative Scale

Every configured peer series shares the exact same quantitative axis and scale.

Shared Quantitative Scale RequirementEvery peer series shares one Y-axis scale. Equal numeric values of 100 produce identical bar heights.1501000Value = 100100Series AHeight: 60px100Series BHeight: 60px100Series CHeight: 60px✓ Shared Scale VerifiedNo hidden dual axes

Scale Integrity Guarantees

  • Equal Lengths for Equal Values: A bar of value 100 in Series A has the exact same quantitative length in pixels as a bar of value 100 in Series B.
  • No Dual Axes: Plotcn rejects dual-axis grouped bars because mismatched scales create optical illusions of parity between incompatible units (e.g. dollars vs. percentages).
  • Mandatory Zero Anchor: Conventional bars encode magnitude via bar length. The quantitative domain automatically anchors at zero (domainMin <= 0 and domainMax >= 0) with 8% headroom padding to prevent clipping.

Dual-Layer Hit Testing & Interaction

Grouped bars often contain narrow bars. Requiring a pixel-perfect hit on a thin rectangle to inspect a category frustrates pointer and touch exploration.

Dual-Layer Hit Testing ArchitectureShows forgiving category-band activation on pointer hover alongside precise bar rectangle hit testing for series-level emphasis.1. Category Band Hit RegionForgiving pointer activation for shared category tooltipActive Category Band (Broad Hit Region)Pointer in Band2. Precise Rectangle FocusSpecific bar intersection emphasizes active series in tooltip & legendActiveDirect Hit

GroupCompareBars resolves this with a dual-layer hit architecture:

  1. Category Band Activation: Hovering anywhere within the broad category band highlights the group and presents the synchronized category tooltip.
  2. Precise Bar Rectangle Focus: Hovering directly over an individual bar highlights that specific series row in the tooltip and legend, maintaining category context without distorting bar geometry.

Responsive Layout Orientation

GroupCompareBars provides an intuitive layout prop with Plotcn semantics:

  • "vertical" (default): Categories along the X-axis; bars grow vertically.
  • "horizontal": Categories along the Y-axis; bars grow horizontally.
Responsive Layout: Vertical vs HorizontalVertical layout for concise category labels versus explicit horizontal layout for lengthy enterprise department names.Vertical Layout (layout="vertical")Best for concise dates, quarters, short codesQ1Q2Q3Horizontal Layout (layout="horizontal")Best for lengthy department & squad namesInfrastructureCustomer SuccessPlatform Eng

Guidelines for Orientation

  • Use Vertical Layout for concise dates, quarters (Q1, Q2), months, or short codes.
  • Use Horizontal Layout whenever category labels are lengthy (e.g. enterprise squads, department names, regional subsidiaries) to avoid unreadable slanted or clipped text.

Rendering Architecture

The data pipeline enforces validation, scale synchronization, and accessibility before rendering Recharts SVG primitives:

Group Compare Bars Architecture FlowDataflow pipeline from consumer data through series validation, zero-inclusive domain calculation, Recharts grouped bars, and inspection surfaces.Consumer Datadata & categoryKeySeries ConfigurationOrdered Peer SeriesValidation & SlotsMissing = Reserved SlotStable ColorsCanonical MapVisible FilteringInteractive LegendZero-Inclusive Domain[Min, Max] includes 0Categorical GroupsgroupGap & barGapRecharts Cartesian Grouped BarsNO stackId • Side-by-Side Peer SlotsXAxis, YAxis, CartesianGrid, ReferenceLine (0)Inspection & Accessible OutputsDual-Tier Focus & Tooltip CardInteractive Legend, Keyboard (Arrows/Home/End), Offscreen Table
  1. Consumer Data Ingestion: Caller dataset and ordered series array ingested immutably.
  2. Data Normalization & Validation: Non-finite values mapped to null, caller category order preserved, duplicate category detection.
  3. Canonical Color Resolution: Colors mapped deterministically from configured array index (--chart-1, --chart-2, etc.).
  4. Zero-Inclusive Shared Domain: Unified domain computed from min and max observed across all visible series.
  5. Recharts Grouped Cartesian Bars: <BarChart> rendered without stackId, allocating side-by-side slots.
  6. Dual-Tier Output: Synchronized category tooltip, interactive legend, keyboard focus, and screen reader HTML <table>.

Component API & Props

Core Props

Property Type Default Description
PropertyTypeDefaultDescription
data *readonly TData[]Readonly array of categorical records. Caller data is never mutated.
categoryKey *keyof TData & stringProperty key on records representing the discrete category domain.
series *readonly GroupCompareBarSeries<TData>[]Ordered array of peer numeric series sharing unit and scale.
layout"vertical" | "horizontal""vertical"Orientation. "vertical" = categories on X; "horizontal" = categories on Y.
heightnumber | string340Container height in pixels or valid CSS string.
domain[number, number]Auto [0, Max]Quantitative scale override. Always anchored at zero by default.

Appearance & Sizing

Property Type Default Description
PropertyTypeDefaultDescription
groupGapnumber20Pixel gap between discrete category groups (barCategoryGap).
barGapnumber4Pixel gap between peer bars within one group (barGap).
maxBarSizenumber36Maximum thickness of individual bars in pixels.
showGridbooleantrueWhether to display dashed reference grid lines.
showXAxisbooleantrueWhether to display the X-axis.
showYAxisbooleantrueWhether to display the Y-axis.
showLegendbooleantrueWhether to display the series legend.
interactiveLegendbooleantrueWhether clicking legend items toggles series visibility.
valueLabel"none" | "auto" | "always""none"Value label display mode above bars.

Formatting & Callbacks

Property Type Default Description
PropertyTypeDefaultDescription
motionboolean | { duration?: number }trueEntrance animation. Automatically disabled under reduced motion.
categoryFormatter(category: string | number) => stringStringFormatter function for categorical axis tick labels.
valueFormatter(value: number) => stringtoLocaleStringGlobal formatter function for quantitative values.
onActiveChange(datum: ActiveGroupCompareDatum | null) => voidCallback fired when active category or series changes.

Interactive Legend & Visibility

When interactiveLegend={true} is enabled:

  • Clicking a series item toggles its visibility globally across all categories.
  • Color Stability: Hiding Series 2 will never cause Series 3 to inherit Series 2's color. Colors remain bound to original configured series identity.
  • All-Series-Hidden State: If all series are toggled off, a recoverable state displays: "All peer series are hidden" with a "Show All Series" button.

Keyboard Navigation

GroupCompareBars implements accessible, single-tab-stop keyboard navigation:

  • Focus: Press Tab to focus the chart region.
  • Vertical Layout:
    • : Move to next category band.
    • : Move to previous category band.
  • Horizontal Layout:
    • : Move to next category band.
    • : Move to previous category band.
  • Home / End: Jump directly to first or last category.
  • Escape: Clear active category selection.

Live announcements narrate category name, current index, and all visible peer series values cleanly to screen readers.

03 / Component API & Styling

Props Reference & Interactive Prop Explorer

Inspect every component property, customize semantic color roles live with instant visual feedback, and copy production-ready code with active prop configurations.

Colors & Appearance Configuration

Customize primary, reference, or annotation series colors. Defaults derive from Plotcn theme tokens.

Generated Usage Code (Live Props):
<GroupCompareBars
  data={data}
  categoryKey="quarter"
  series={[
    { key: "current", label: "Current Year" },
    { key: "previous", label: "Previous Year" },
  ]}
/>
Interactive Prop Preview Lab
layout"vertical" | "horizontal"

Orientation of the chart. "vertical" places categories on X with vertical bars; "horizontal" places categories on Y with horizontal bars for long labels.

Select value to preview live:
Active: layout="vertical"Default: "vertical"
groupGapnumber

Pixel gap between discrete category groups (barCategoryGap).

Select value to preview live:
Active: groupGap={20}Default: 20
barGapnumber

Pixel gap between peer bars within one categorical group.

Select value to preview live:
Active: barGap={4}Default: 4
maxBarSizenumber

Maximum quantitative thickness of individual bars in pixels.

Select value to preview live:
Active: maxBarSize={36}Default: 36
showGridboolean

Whether to render subtle dashed reference grid lines.

Select value to preview live:
Active: showGrid={true}Default: true
showLegendboolean

Whether to display the interactive series legend.

Select value to preview live:
Active: showLegend={true}Default: true
interactiveLegendboolean

Whether clicking legend items toggles series visibility.

Select value to preview live:
Active: interactiveLegend={true}Default: true
valueLabel"none" | "auto" | "always"

Value label rendering mode: "none", "auto" (fit-aware), or "always".

Select value to preview live:
Active: valueLabel="none"Default: "none"
All Properties (19)
Component properties
PropertyTypeDefaultRequiredDescription
dataReq
readonly TData[][]Yes

Readonly array of categorical records. Caller data is never mutated or reordered.

keyof TData & stringYes

Property name on data records representing the discrete category domain.

readonly GroupCompareBarSeries<TData>[]Yes

Array of peer numeric series sharing the same quantitative unit, domain, and scale.

"vertical" | "horizontal""vertical"No

Orientation of the chart. "vertical" places categories on X with vertical bars; "horizontal" places categories on Y with horizontal bars for long labels.

number | string340No

Height of the chart container in pixels or valid CSS string.

[number, number]Auto [min <= 0, max >= 0 + 8%]No

Explicit quantitative domain override. Always anchored at zero by default.

number20No

Pixel gap between discrete category groups (barCategoryGap).

number4No

Pixel gap between peer bars within one categorical group.

number36No

Maximum quantitative thickness of individual bars in pixels.

booleantrueNo

Whether to render subtle dashed reference grid lines.

booleantrueNo

Whether to display the categorical or numeric X-axis.

booleantrueNo

Whether to display the quantitative or categorical Y-axis.

booleantrueNo

Whether to display the interactive series legend.

booleantrueNo

Whether clicking legend items toggles series visibility.

"none" | "auto" | "always""none"No

Value label rendering mode: "none", "auto" (fit-aware), or "always".

boolean | { duration?: number }trueNo

Animation configuration. Automatically disabled under prefers-reduced-motion.

(category: string | number) => stringString(category)No

Custom formatter function for categorical axis labels.

(value: number) => stringvalue.toLocaleString()No

Global formatter function for numeric values.

(datum: ActiveGroupCompareDatum<TData> | null) => voidNo

Callback fired when active category index or series key changes.

04 / Cookbook & States

Component Variants & Edge States

Production cookbooks showcasing configuration variants alongside verified handling of loading, empty data, and network error states.

Quarterly Revenue Comparison

Side-by-side comparison of Current Year vs. Previous Year quarterly performance.

<GroupCompareBars
  data={[
    { quarter: "Q1", current: 184000, previous: 163000 },
    { quarter: "Q2", current: 216000, previous: 191000 },
    { quarter: "Q3", current: 228000, previous: 207000 },
    { quarter: "Q4", current: 252000, previous: 236000 },
  ]}
  categoryKey="quarter"
  series={[
    { key: "current", label: "Current Year", color: "var(--chart-1)" },
    { key: "previous", label: "Previous Year", color: "var(--chart-2)" },
  ]}
  valueFormatter={(v) => "$" + (v / 1000).toFixed(0) + "k"}
/>

Signups by Channel (3 Series)

Three-way comparison across Web, Mobile, and Partner channels.

<GroupCompareBars
  data={[
    { month: "Jan", web: 4200, mobile: 5100, partner: 1800 },
    { month: "Feb", web: 4600, mobile: 5400, partner: 2100 },
    { month: "Mar", web: 5100, mobile: 6200, partner: 2400 },
    { month: "Apr", web: 5400, mobile: 6100, partner: 2900 },
  ]}
  categoryKey="month"
  series={[
    { key: "web", label: "Web", color: "var(--chart-1)" },
    { key: "mobile", label: "Mobile", color: "var(--chart-2)" },
    { key: "partner", label: "Partner", color: "var(--chart-3)" },
  ]}
/>

Horizontal Enterprise Departments

Explicit horizontal orientation recommended for lengthy enterprise labels.

<GroupCompareBars
  data={[
    { department: "Enterprise Customer Success", headcount2025: 48, headcount2026: 62 },
    { department: "Government & Public Sector", headcount2025: 34, headcount2026: 41 },
    { department: "International Operations", headcount2025: 56, headcount2026: 59 },
    { department: "Platform & Core Systems", headcount2025: 72, headcount2026: 88 },
  ]}
  categoryKey="department"
  layout="horizontal"
  height={300}
  series={[
    { key: "headcount2025", label: "2025 Actual", color: "var(--chart-1)" },
    { key: "headcount2026", label: "2026 Target", color: "var(--chart-2)" },
  ]}
/>

Missing Data Slot Reservation

Demonstrating how missing data reserves its slot without bar impersonation.

<GroupCompareBars
  data={[
    { quarter: "Q1", web: 420, mobile: 510, partner: 180 },
    { quarter: "Q2", web: 480, mobile: null, partner: 240 },
    { quarter: "Q3", web: 540, mobile: 630, partner: 290 },
  ]}
  categoryKey="quarter"
  series={[
    { key: "web", label: "Web" },
    { key: "mobile", label: "Mobile" },
    { key: "partner", label: "Partner" },
  ]}
/>

Mixed-Sign Value Comparison

Finite negative values extend downward from the shared zero baseline.

<GroupCompareBars
  data={[
    { region: "North", netGrowth: 18, churnRisk: -6 },
    { region: "South", netGrowth: 24, churnRisk: -4 },
    { region: "East", netGrowth: -8, churnRisk: -14 },
    { region: "West", netGrowth: 12, churnRisk: -9 },
  ]}
  categoryKey="region"
  series={[
    { key: "netGrowth", label: "Net Growth %", color: "var(--chart-1)" },
    { key: "churnRisk", label: "Churn Risk %", color: "var(--chart-5)" },
  ]}
  valueFormatter={(v) => v + "%"}
/>
Lifecycle & Exception States
01. Loading State

Skeletons indicate runtime fetch or pending data queries.

02. Empty Data State

Handles empty collections ([]) gracefully without crashing.

03. Error State

Graceful failure banner when data source or script fails.

05 / Responsive Lab

Container-Driven Breakpoints

Group Compare Bars maintains full side-by-side peer comparison fidelity across all breakpoints down to 320px. Category ticks thin adaptively, while all configured peer series remain visible and truthful without silent removal.

Desktop
>= 1024px

Full category tick density, inline peer bars with full spacing, and comprehensive synchronized tooltips.

Tablet
640px - 1023px

Adaptive category tick thinning, compact margins, and preserved 44px touch interaction targets.

Mobile
< 640px

Thinned category ticks, wrapped legend buttons, 44px touch targets, vertical page scroll preservation, and compact card tooltips.

Tablet Preview (768px Container Constraint)
Mobile Preview (390px Container Constraint)
06 / Assistive Technology

Accessibility & Navigation Standards

Single tab stop on root figure with orientation-aware arrow navigation (Left/Right for vertical, Up/Down for horizontal), Home/End traversal, polite ARIA live announcements, and full off-screen structured HTML table for assistive devices.

Semantic Role & Landmark

Container mounts as region with explicit assistive label.

Color-Independent Legibility

Deterministic horizontal/vertical series slot positions, explicit legend labels, category tooltips, and complete off-screen HTML table ensure complete non-color accessibility even when series share identical hues.

Screen Reader Summary

Embeds visually hidden summary (.sr-only) declaring: “Announces category name, group index, total categories, and all peer series values factually without subjective leader/winner assumptions.

Reduced Motion Support

All initial entrance animations are bypassed immediately when prefers-reduced-motion is detected.

Keyboard Interaction Model
Keyboard interaction model
KeyAction
ArrowRight / ArrowDownInspect next categorical group across the discrete domain.
ArrowLeft / ArrowUpInspect previous categorical group across the discrete domain.
HomeJump inspection directly to the first category.
EndJump inspection directly to the last category.
EscapeClear active group inspection.

Data Safety Checklist

  • [x] Preserved Category Order: Source array order is strictly respected; no automatic sorting.
  • [x] Preserved Series Order: Configured series order is invariant across all categories.
  • [x] Shared Quantitative Scale: All peer series share one Y-axis scale; no dual axes.
  • [x] Zero Baseline: Quantitative domain always includes zero; no truncated floating bars.
  • [x] Zero Is Valid: Observations of 0 render a zero-height bar, not an error or fake rectangle.
  • [x] Negative Support: Negative values extend downward from zero baseline truthfully.
  • [x] Missing Slot Reservation: Missing values render no bar but preserve slot spacing.
  • [x] No Bar Impersonation: Surviving bars never shift into an absent peer's position.
  • [x] Stable Colors: Series colors derive from canonical configuration order, not visible index.
  • [x] Recoverable Hidden State: Hiding all series displays an actionable recovery UI.
  • [x] Responsive Integrity: Responsive adaptation reduces ticks and labels before content; never silently drops a series.
  • [x] Immutable Data: Caller datasets and series arrays are never mutated.
07 / Source Anatomy

Internal Architecture & File Dependencies

Source-first ownership model. Inspect the exact component call tree, dependencies, and full implementation below.

Component Architecture Call Tree
GroupCompareBars(Semantic figure and keyboard navigation entry point)
├──ChartContainer[Responsive container]

Manages container aspect ratio, dimensions, and fluid SVG scaling

├──Legend Toolbar[Interactive series controls]

Accessible toolbar with series color swatches and interactive visibility toggling

└──Screen Reader Table[Accessible data alternative]

Offscreen HTML table providing complete data access for assistive technologies

Involved Source Files & Registry Assets
registry/recharts/bar-group-compare.tsx
Complete Group Compare Bars component for side-by-side multi-series categorical comparison with stable series identity
registry/recharts/bar-group-compare.tsx
"use client"import * as React from "react"import {  BarChart,  Bar,  XAxis,  YAxis,  CartesianGrid,  Tooltip,  ResponsiveContainer,  LabelList,  ReferenceLine,} from "recharts"import { cn } from "@/lib/utils"import { ChartContainer } from "../shared/chart-container"import {  ChartEmptyState,  ChartErrorState,  ChartUnavailableState,} from "../shared/chart-state"import { useChartReducedMotion } from "../shared/use-chart-reduced-motion"/* -------------------------------------------------------------------------- *//*  Types & Contracts                                                         *//* -------------------------------------------------------------------------- */export type NumericKeyOf<T> = {  [K in keyof T]: T[K] extends number | null | undefined ? K : never}[keyof T] &  stringexport interface GroupCompareBarSeries<TData = Record<string, unknown>> {  /** Property key on data record representing numeric measure */  readonly key: NumericKeyOf<TData>  /** Human-readable label for legend, tooltips, and accessibility */  readonly label: string  /** Explicit CSS color (e.g. "var(--chart-1)", "#3b82f6"). Overrides theme cycling */  readonly color?: string  /** Series-specific value formatter */  readonly valueFormatter?: (value: number) => string}export interface ActiveGroupCompareDatum<TData = Record<string, unknown>> {  readonly index: number  readonly category: string | number  readonly raw: TData  readonly values: Record<string, number | null>  readonly activeSeriesKey: string | null}export interface GroupCompareBarsProps<TData = Record<string, unknown>> {  /** Readonly array of categorical records. Caller order is strictly preserved */  readonly data: readonly TData[]  /** Field name identifying the discrete category domain */  readonly categoryKey: keyof TData & string  /** Configured peer numeric series sharing the same quantitative unit and scale */  readonly series: readonly GroupCompareBarSeries<TData>[]  /** Overall container height in pixels or CSS string. Defaults to 340 */  readonly height?: number | string  /** Layout orientation: "vertical" (categories on X) or "horizontal" (categories on Y) */  readonly layout?: "vertical" | "horizontal"  /** Explicit quantitative scale domain override [min, max] */  readonly domain?: [number, number]  /** Pixel gap between category groups (barCategoryGap). Defaults to 20 */  readonly groupGap?: number  /** Pixel gap between peer bars within one group (barGap). Defaults to 4 */  readonly barGap?: number  /** Maximum bar thickness in pixels. Defaults to 36 */  readonly maxBarSize?: number  /** Whether to render subtle background grid lines. Defaults to true */  readonly showGrid?: boolean  /** Whether to render categorical axis. Defaults to true */  readonly showXAxis?: boolean  /** Whether to render quantitative axis. Defaults to true */  readonly showYAxis?: boolean  /** Whether to show the series legend. Defaults to true */  readonly showLegend?: boolean  /** Enable interactive legend series toggling. Defaults to true */  readonly interactiveLegend?: boolean  /** Value label mode: "none", "auto" (fit-aware), or "always". Defaults to "none" */  readonly valueLabel?: "none" | "auto" | "always"  /** Restrained entrance animation. Defaults to true */  readonly motion?: boolean | { duration?: number }  /** Formatter for categorical axis tick labels */  readonly categoryFormatter?: (category: string | number) => string  /** Global formatter for quantitative values */  readonly valueFormatter?: (value: number) => string  /** Callback fired when active category or series changes */  readonly onActiveChange?: (datum: ActiveGroupCompareDatum<TData> | null) => void  /** Optional CSS class name */  readonly className?: string}/* -------------------------------------------------------------------------- *//*  Internal Helpers & Normalization                                          *//* -------------------------------------------------------------------------- */const DEFAULT_SERIES_TOKENS = [  "var(--chart-1, #3b82f6)",  "var(--chart-2, #10b981)",  "var(--chart-3, #f59e0b)",  "var(--chart-4, #a855f7)",  "var(--chart-5, #ec4899)",  "var(--chart-6, #06b6d4)",]export function isFiniteNumber(val: unknown): val is number {  return typeof val === "number" && Number.isFinite(val)}export interface NormalizedGroupCompareRecord<TData = Record<string, unknown>> {  __index: number  __category: string | number  __raw: TData  [seriesKey: string]: unknown}/** * Normalizes input records into safe internal data structures. * - Caller category order is strictly preserved. * - Configured series order is strictly preserved. * - Non-finite values (null, undefined, NaN, Infinity) are sanitized to null so Recharts reserves slot without drawing fake bars. * - Zero is preserved as 0. * - Finite negative values are preserved. */export function normalizeGroupCompareData<TData extends Record<string, unknown> = Record<string, unknown>>(  data: readonly TData[],  categoryKey: keyof TData & string,  series: readonly GroupCompareBarSeries<TData>[],  visibleSeriesKeys: readonly string[]): {  records: NormalizedGroupCompareRecord<TData>[]  hasAnyValidMeasure: boolean  hasDuplicates: boolean  minObserved: number  maxObserved: number} {  const seenCategories = new Set<string | number>()  let hasDuplicates = false  let hasAnyValidMeasure = false  let minObserved = 0  let maxObserved = 0  const visibleKeySet = new Set(visibleSeriesKeys)  const records: NormalizedGroupCompareRecord<TData>[] = []  for (let i = 0; i < data.length; i++) {    const raw = data[i]    const cat = (raw[categoryKey] as string | number) ?? `Category ${i + 1}`    if (seenCategories.has(cat)) {      hasDuplicates = true    } else {      seenCategories.add(cat)    }    const rec: NormalizedGroupCompareRecord<TData> = {      __index: i,      __category: cat,      __raw: raw,    }    for (let s = 0; s < series.length; s++) {      const sKey = series[s].key as string      const rawVal = raw[sKey]      if (isFiniteNumber(rawVal)) {        rec[sKey] = rawVal        hasAnyValidMeasure = true        if (visibleKeySet.has(sKey)) {          if (rawVal < minObserved) minObserved = rawVal          if (rawVal > maxObserved) maxObserved = rawVal        }      } else {        // Missing, null, undefined, NaN, Infinity        rec[sKey] = null      }    }    records.push(rec)  }  return {    records,    hasAnyValidMeasure,    hasDuplicates,    minObserved,    maxObserved,  }}/** * Calculates a zero-inclusive shared quantitative domain. * - Always includes 0 (mandatory for conventional bars). * - Adapts to all-positive, all-negative, or mixed-sign data. * - Applies a safe 8% headroom padding to prevent bars from clipping the plot edge. */export function calculateGroupCompareDomain(  minVal: number,  maxVal: number,  explicitDomain?: [number, number]): [number, number] {  if (explicitDomain) {    return explicitDomain  }  let domainMin = Math.min(0, minVal)  let domainMax = Math.max(0, maxVal)  if (domainMin === 0 && domainMax === 0) {    return [0, 10]  }  const range = domainMax - domainMin  const padding = range * 0.08  if (domainMax > 0) {    domainMax += padding  }  if (domainMin < 0) {    domainMin -= padding  }  return [Math.floor(domainMin), Math.ceil(domainMax)]}/* -------------------------------------------------------------------------- *//*  Component: GroupCompareBars                                               *//* -------------------------------------------------------------------------- */export function GroupCompareBars<TData extends Record<string, unknown> = Record<string, unknown>>({  data,  categoryKey,  series,  layout = "vertical",  height = 340,  domain: explicitDomain,  groupGap = 20,  barGap = 4,  maxBarSize = 36,  showGrid = true,  showXAxis = true,  showYAxis = true,  showLegend = true,  interactiveLegend = true,  valueLabel = "none",  motion = true,  categoryFormatter,  valueFormatter: globalValueFormatter,  onActiveChange,  className,}: GroupCompareBarsProps<TData>) {  const reducedMotion = useChartReducedMotion()  const safeSeries = React.useMemo(() => series ?? [], [series])  const safeCategoryKey = categoryKey ?? ""  const safeData = React.useMemo(() => data ?? [], [data])  // Interactive peer series visibility state  const [hiddenSeriesKeys, setHiddenSeriesKeys] = React.useState<Set<string>>(() => new Set())  const visibleSeries = React.useMemo(() => {    return safeSeries.filter((s) => !hiddenSeriesKeys.has(s.key as string))  }, [safeSeries, hiddenSeriesKeys])  const visibleSeriesKeys = React.useMemo(() => {    return visibleSeries.map((s) => s.key as string)  }, [visibleSeries])  // Stable series color mapping: Hiding a series NEVER reassigns colors of remaining series  const seriesColorMap = React.useMemo(() => {    const map = new Map<string, string>()    safeSeries.forEach((s, idx) => {      map.set(s.key as string, s.color || DEFAULT_SERIES_TOKENS[idx % DEFAULT_SERIES_TOKENS.length])    })    return map  }, [safeSeries])  // Normalize and validate dataset  const { records, hasAnyValidMeasure, hasDuplicates, minObserved, maxObserved } = React.useMemo(() => {    if (!safeCategoryKey || safeSeries.length === 0) {      return {        records: [],        hasAnyValidMeasure: false,        hasDuplicates: false,        minObserved: 0,        maxObserved: 0,      }    }    return normalizeGroupCompareData(safeData, safeCategoryKey, safeSeries, visibleSeriesKeys)  }, [safeData, safeCategoryKey, safeSeries, visibleSeriesKeys])  // Surface development warnings for data anomalies  React.useEffect(() => {    if (process.env.NODE_ENV !== "production" && safeCategoryKey && safeSeries.length > 0) {      if (hasDuplicates) {        console.warn(          `[Plotcn GroupCompareBars] Duplicate category keys detected for "${safeCategoryKey}". ` +            "Category order and records are preserved strictly, but unique labels are recommended."        )      }    }  }, [hasDuplicates, safeCategoryKey, safeSeries.length])  // Domain calculation  const quantitativeDomain = React.useMemo(() => {    return calculateGroupCompareDomain(minObserved, maxObserved, explicitDomain)  }, [minObserved, maxObserved, explicitDomain])  // Active inspection state (category band + optional exact series)  const [activeIndex, setActiveIndex] = React.useState<number | null>(null)  const [activeSeriesKey, setActiveSeriesKey] = React.useState<string | null>(null)  const [isFocused, setIsFocused] = React.useState(false)  const activeDatum = React.useMemo<ActiveGroupCompareDatum<TData> | null>(() => {    if (activeIndex === null || activeIndex < 0 || activeIndex >= records.length) {      return null    }    const rec = records[activeIndex]    const values: Record<string, number | null> = {}    for (let s = 0; s < safeSeries.length; s++) {      const sKey = safeSeries[s].key as string      const rawVal = rec[sKey]      values[sKey] = isFiniteNumber(rawVal) ? rawVal : null    }    return {      index: activeIndex,      category: rec.__category,      raw: rec.__raw,      values,      activeSeriesKey,    }  }, [activeIndex, records, safeSeries, activeSeriesKey])  React.useEffect(() => {    onActiveChange?.(activeDatum)  }, [activeDatum, onActiveChange])  // Validate configuration guards  if (!series || series.length === 0) {    return (      <div style={{ height }} className={cn("w-full", className)}>        <ChartErrorState          title="No Peer Series Defined"          description="GroupCompareBars requires at least one peer series in its series configuration."        />      </div>    )  }  if (!categoryKey) {    return (      <div style={{ height }} className={cn("w-full", className)}>        <ChartErrorState          title="Missing Category Key"          description="A valid categoryKey is required to identify discrete categorical groups."        />      </div>    )  }  // Keyboard navigation handlers  const isVertical = layout === "vertical"  const rechartsLayout = isVertical ? "horizontal" : "vertical"  const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {    if (records.length === 0) return    const prevKey = isVertical ? "ArrowLeft" : "ArrowUp"    const nextKey = isVertical ? "ArrowRight" : "ArrowDown"    if (e.key === prevKey) {      e.preventDefault()      setActiveIndex((prev) => {        if (prev === null || prev <= 0) return records.length - 1        return prev - 1      })    } else if (e.key === nextKey) {      e.preventDefault()      setActiveIndex((prev) => {        if (prev === null || prev >= records.length - 1) return 0        return prev + 1      })    } else if (e.key === "Home") {      e.preventDefault()      setActiveIndex(0)    } else if (e.key === "End") {      e.preventDefault()      setActiveIndex(records.length - 1)    } else if (e.key === "Escape") {      setActiveIndex(null)      setActiveSeriesKey(null)    }  }  // Motion configuration  const isAnimated = motion !== false && !reducedMotion  const animationDuration =    typeof motion === "object" && motion?.duration !== undefined ? motion.duration * 1000 : 350  // Empty / fallback guards  if (!records.length) {    return (      <div style={{ height }} className={cn("w-full", className)}>        <ChartEmptyState          title="No Categorical Data"          description="The provided dataset contains no records to compare."        />      </div>    )  }  if (!hasAnyValidMeasure) {    return (      <div style={{ height }} className={cn("w-full", className)}>        <ChartUnavailableState          title="No Comparable Values"          description="Categorical records are present, but all numeric measures are unrecorded or non-finite."        />      </div>    )  }  // Legend toggle handler  const toggleSeries = (sKey: string) => {    if (!interactiveLegend) return    setHiddenSeriesKeys((prev) => {      const next = new Set(prev)      if (next.has(sKey)) {        next.delete(sKey)      } else {        next.add(sKey)      }      return next    })  }  const showAllSeries = () => {    setHiddenSeriesKeys(new Set())  }  // Formatting helpers  const formatValue = (val: number | null | undefined, seriesItem?: GroupCompareBarSeries<TData>): string => {    if (val === null || val === undefined || !Number.isFinite(val)) return "Unavailable"    if (seriesItem?.valueFormatter) return seriesItem.valueFormatter(val)    if (globalValueFormatter) return globalValueFormatter(val)    return val.toLocaleString()  }  const formatCategory = (cat: string | number): string => {    if (categoryFormatter) return categoryFormatter(cat)    return String(cat)  }  // Accessible live announcement  const liveAnnouncement = activeDatum    ? `${formatCategory(activeDatum.category)}. Group ${activeDatum.index + 1} of ${records.length}. ` +      visibleSeries        .map((s) => `${s.label}: ${formatValue(activeDatum.values[s.key as string], s)}`)        .join(", ") +      "."    : ""  return (    <figure      role="region"      aria-label={`Group Compare Bars side-by-side comparison for ${series.map((s) => s.label).join(", ")}`}      tabIndex={0}      onKeyDown={handleKeyDown}      onFocus={() => {        setIsFocused(true)        if (activeIndex === null && records.length > 0) {          setActiveIndex(0)        }      }}      onBlur={() => {        setIsFocused(false)      }}      className={cn(        "plotcn-bar-group-compare relative flex flex-col w-full outline-none select-none transition-all duration-150 rounded-xl",        isFocused && "ring-2 ring-[var(--chart-focus,#38bdf8)] ring-offset-2 ring-offset-background",        className      )}      style={{        height: typeof height === "number" ? `${height}px` : height,        minHeight: typeof height === "number" ? height : 340,        touchAction: "pan-y",      }}    >      {/* Screen Reader Live Region Announcement */}      <div className="sr-only" aria-live="polite" aria-atomic="true">        {liveAnnouncement}      </div>      {/* Screen Reader Accessible HTML Table Alternative */}      <div className="sr-only">        <table>          <caption>            {`Grouped comparison table with ${records.length} categories and ${visibleSeries.length} visible peer series.`}          </caption>          <thead>            <tr>              <th scope="col">Category</th>              {series.map((s) => (                <th key={s.key as string} scope="col">                  {s.label}                </th>              ))}            </tr>          </thead>          <tbody>            {records.map((r, i) => (              <tr key={i}>                <th scope="row">{formatCategory(r.__category)}</th>                {series.map((s) => {                  const sKey = s.key as string                  const val = r[sKey]                  return (                    <td key={sKey}>                      {isFiniteNumber(val) ? formatValue(val, s) : "Unavailable"}                    </td>                  )                })}              </tr>            ))}          </tbody>        </table>      </div>      {/* Primary SVG Rendering Canvas via Recharts */}      <ChartContainer className="w-full flex-1 min-w-0 min-h-0 relative">        {/* Recoverable All-Series-Hidden State Overlay */}        {visibleSeries.length === 0 && (          <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/80 backdrop-blur-xs z-20 text-center p-4">            <p className="text-sm font-medium text-foreground">All peer series are hidden.</p>            <p className="mt-1 text-xs text-muted-foreground">              Toggle series in the legend below or restore all series to view side-by-side comparisons.            </p>            <button              type="button"              onClick={showAllSeries}              className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-muted/30 hover:bg-muted text-xs font-mono font-medium text-foreground transition-colors cursor-pointer"            >              Show all series            </button>          </div>        )}        <ResponsiveContainer          width="100%"          height="100%"          minWidth={0}          minHeight={0}          initialDimension={{ width: 320, height: typeof height === "number" ? height : 340 }}        >          <BarChart            data={records}            layout={rechartsLayout}            barCategoryGap={groupGap}            barGap={barGap}            margin={              isVertical                ? { top: 16, right: 16, left: 4, bottom: 4 }                : { top: 12, right: 32, left: 16, bottom: 4 }            }            onMouseMove={(state: unknown) => {              const chartState = state as { activeTooltipIndex?: number } | null              if (chartState && typeof chartState.activeTooltipIndex === "number") {                if (chartState.activeTooltipIndex !== activeIndex) {                  setActiveIndex(chartState.activeTooltipIndex)                }              }            }}            onMouseLeave={() => {              setActiveIndex(null)              setActiveSeriesKey(null)            }}          >            {showGrid && (              <CartesianGrid                strokeDasharray="3 3"                vertical={!isVertical}                horizontal={isVertical}                stroke="var(--chart-grid, rgba(255, 255, 255, 0.08))"              />            )}            {/* Zero Reference Line Anchor */}            <ReferenceLine              x={!isVertical ? 0 : undefined}              y={isVertical ? 0 : undefined}              stroke="var(--border, rgba(255, 255, 255, 0.2))"              strokeWidth={1}            />            {/* X-Axis */}            {isVertical ? (              <XAxis                dataKey="__category"                hide={!showXAxis}                tickLine={false}                axisLine={{ stroke: "var(--border, rgba(255, 255, 255, 0.15))" }}                tick={{ fill: "var(--chart-axis, #a1a1aa)", fontSize: 11 }}                tickFormatter={formatCategory}              />            ) : (              <XAxis                type="number"                domain={quantitativeDomain}                hide={!showXAxis}                tickLine={false}                axisLine={{ stroke: "var(--border, rgba(255, 255, 255, 0.15))" }}                tick={{ fill: "var(--chart-axis, #a1a1aa)", fontSize: 11 }}                tickFormatter={(val: number) => formatValue(val)}              />            )}            {/* Y-Axis */}            {isVertical ? (              <YAxis                domain={quantitativeDomain}                hide={!showYAxis}                tickLine={false}                axisLine={{ stroke: "var(--border, rgba(255, 255, 255, 0.15))" }}                tick={{ fill: "var(--chart-axis, #a1a1aa)", fontSize: 11 }}                tickFormatter={(val: number) => formatValue(val)}                width={48}              />            ) : (              <YAxis                type="category"                dataKey="__category"                hide={!showYAxis}                tickLine={false}                axisLine={{ stroke: "var(--border, rgba(255, 255, 255, 0.15))" }}                tick={{ fill: "var(--chart-axis, #a1a1aa)", fontSize: 11 }}                tickFormatter={formatCategory}                width={80}              />            )}            {/* Category-Centric Tooltip Card */}            <Tooltip              allowEscapeViewBox={{ x: false, y: false }}              content={({ active, payload, label }) => {                if (!active || !payload || !payload.length) return null                const categoryTitle = label !== undefined ? formatCategory(label) : ""                return (                  <div                    className="plotcn-chart-tooltip flex flex-col gap-2 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-md backdrop-blur-md min-w-[min(180px,calc(100cqw-16px))] max-w-[min(300px,calc(100cqw-16px))] max-h-[calc(100cqh-16px)] overflow-y-auto"                    role="tooltip"                  >                    <div className="flex items-center justify-between border-b border-border/60 pb-1.5">                      <span className="font-semibold text-xs text-foreground tracking-tight">                        {categoryTitle}                      </span>                      <span className="text-[10px] font-mono text-muted-foreground uppercase">                        Group Comparison                      </span>                    </div>                    <div className="flex flex-col gap-1.5 pt-0.5">                      {series.map((s) => {                        const sKey = s.key as string                        const isHidden = hiddenSeriesKeys.has(sKey)                        if (isHidden) return null                        const color = seriesColorMap.get(sKey) || "var(--chart-1)"                        const isHoveredSeries = activeSeriesKey === sKey                        const currentRecord = records.find((r) => r.__category === label)                        const rawVal = currentRecord ? currentRecord[sKey] : null                        const hasVal = isFiniteNumber(rawVal)                        return (                          <div                            key={sKey}                            className={cn(                              "flex items-center justify-between gap-3 text-xs py-0.5 px-1 rounded transition-colors",                              isHoveredSeries && "bg-accent/40 font-medium"                            )}                          >                            <div className="flex items-center gap-1.5 truncate">                              <span                                className="h-2 w-2 rounded-sm shrink-0"                                style={{ backgroundColor: color }}                                aria-hidden="true"                              />                              <span className="truncate text-muted-foreground">{s.label}</span>                            </div>                            <span                              className={cn(                                "font-mono tabular-nums text-foreground",                                !hasVal && "text-muted-foreground/70 italic text-[11px]"                              )}                            >                              {hasVal ? formatValue(rawVal, s) : "Unavailable"}                            </span>                          </div>                        )                      })}                    </div>                  </div>                )              }}            />            {/* Grouped Bars: Side-by-Side WITHOUT stackId */}            {visibleSeries.map((s) => {              const sKey = s.key as string              const color = seriesColorMap.get(sKey) || "var(--chart-1)"              // Subtle rounding on outer value end; grounded baseline corners remain flat              const radius: [number, number, number, number] = isVertical                ? [4, 4, 0, 0]                : [0, 4, 4, 0]              return (                <Bar                  key={sKey}                  dataKey={sKey}                  name={s.label}                  fill={color}                  stroke="var(--background, #09090b)"                  strokeWidth={1}                  maxBarSize={maxBarSize}                  radius={radius}                  isAnimationActive={isAnimated}                  animationDuration={animationDuration}                  animationEasing="ease-out"                  onMouseEnter={() => {                    setActiveSeriesKey(sKey)                  }}                  onMouseLeave={() => {                    setActiveSeriesKey(null)                  }}                >                  {/* Optional Value Labels */}                  {valueLabel === "always" && (                    <LabelList                      dataKey={sKey}                      position={isVertical ? "top" : "right"}                      fill="var(--chart-axis, #a1a1aa)"                      fontSize={10}                      offset={4}                      formatter={(val: unknown) => {                        if (!isFiniteNumber(val)) return ""                        return formatValue(val, s)                      }}                    />                  )}                  {valueLabel === "auto" && (                    <LabelList                      dataKey={sKey}                      position="center"                      fill="#ffffff"                      fontSize={9}                      formatter={(val: unknown) => {                        if (!isFiniteNumber(val) || Math.abs(val) <= 0) return ""                        return val.toLocaleString()                      }}                    />                  )}                </Bar>              )            })}          </BarChart>        </ResponsiveContainer>      </ChartContainer>      {/* Accessible & Interactive Peer Series Legend */}      {showLegend && (        <div          className="mt-3 shrink-0 flex flex-wrap items-center justify-center gap-4 pt-2 border-t border-border/50 text-xs text-muted-foreground"          role="toolbar"          aria-label="Series visibility controls"        >          {series.map((s) => {            const sKey = s.key as string            const isHidden = hiddenSeriesKeys.has(sKey)            const color = seriesColorMap.get(sKey) || "var(--chart-1)"            const isHovered = activeSeriesKey === sKey            return (              <button                key={sKey}                type="button"                role="checkbox"                aria-checked={!isHidden}                disabled={!interactiveLegend}                onClick={() => toggleSeries(sKey)}                onMouseEnter={() => setActiveSeriesKey(sKey)}                onMouseLeave={() => setActiveSeriesKey(null)}                className={cn(                  "inline-flex items-center gap-1.5 rounded px-2 py-1 transition-all focus:outline-none focus-visible:ring-1 focus-visible:ring-ring",                  interactiveLegend ? "cursor-pointer hover:bg-accent/40" : "cursor-default",                  isHidden && "opacity-40 line-through grayscale",                  isHovered && "ring-1 ring-ring/30 bg-accent/30 text-foreground font-medium"                )}              >                <span                  className="h-2.5 w-2.5 rounded-sm shrink-0 transition-transform"                  style={{ backgroundColor: color }}                  aria-hidden="true"                />                <span className="text-foreground text-[11px] select-none">{s.label}</span>              </button>            )          })}        </div>      )}    </figure>  )}