From 20125d953d4bf1d175367b4bd1a47b0635ce5b22 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 09:35:58 +1100 Subject: [PATCH 01/17] Add CategoryBar component type definitions --- .../src/components/category-bar/types.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 projects/js-packages/charts/src/components/category-bar/types.ts diff --git a/projects/js-packages/charts/src/components/category-bar/types.ts b/projects/js-packages/charts/src/components/category-bar/types.ts new file mode 100644 index 0000000000000..b31f2a623aa93 --- /dev/null +++ b/projects/js-packages/charts/src/components/category-bar/types.ts @@ -0,0 +1,138 @@ +/** + * Single segment in the category bar + */ +export interface CategoryBarSegment { + /** + * Numeric value for this segment. + * In 'proportional' mode, determines segment width relative to total. + * In 'equal' mode, used for tooltip/label display only. + */ + value: number; + + /** + * Optional label for the segment (used in tooltips) + */ + label?: string; + + /** + * Optional color override for this segment. + * If not provided, uses theme colors by index. + */ + color?: string; +} + +/** + * Marker configuration for indicating a position on the bar + */ +export interface CategoryBarMarker { + /** + * Position value where the marker should appear. + * Interpreted as cumulative value from left (0 to total). + */ + value: number; + + /** + * Optional tooltip text for the marker + */ + tooltip?: string; + + /** + * Whether to animate the marker position + * @default false + */ + showAnimation?: boolean; + + /** + * Marker color (defaults to theme text color) + */ + color?: string; +} + +/** + * Display mode for segment sizing + */ +export type CategoryBarMode = 'proportional' | 'equal'; + +export interface CategoryBarProps { + /** + * Array of segments to display. + * Can be simple numbers or full segment objects. + */ + values: number[] | CategoryBarSegment[]; + + /** + * Display mode for segments. + * - 'proportional': Segment widths based on their values (default) + * - 'equal': All segments have equal width + * @default 'proportional' + */ + mode?: CategoryBarMode; + + /** + * Custom colors for segments (overrides theme colors) + * Applied in order to segments + */ + colors?: string[]; + + /** + * Optional marker to indicate a position on the bar + */ + marker?: CategoryBarMarker; + + /** + * Whether to show cumulative value labels below the bar + * @default true + */ + showLabels?: boolean; + + /** + * Width of the bar in pixels + * @default 300 + */ + width?: number; + + /** + * Height of the bar in pixels + * @default 8 + */ + height?: number; + + /** + * Size (used by responsive variant) + */ + size?: number; + + /** + * Gap between segments in pixels + * @default 0 + */ + gap?: number; + + /** + * Corner radius for the bar ends + * @default 4 + */ + borderRadius?: number; + + /** + * Additional CSS class name + */ + className?: string; + + /** + * Chart ID for unique element identification + */ + chartId?: string; + + /** + * Format function for label values + * @default (value) => value.toString() + */ + labelFormatter?: ( value: number ) => string; + + /** + * Whether to show tooltips on hover + * @default false + */ + withTooltips?: boolean; +} From 0789d600ae99e848c59513a72b2fdc5b35f20995 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 09:38:30 +1100 Subject: [PATCH 02/17] Add CategoryBar component implementation and styles --- .../category-bar/category-bar.module.scss | 95 +++++ .../components/category-bar/category-bar.tsx | 345 ++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 projects/js-packages/charts/src/components/category-bar/category-bar.module.scss create mode 100644 projects/js-packages/charts/src/components/category-bar/category-bar.tsx diff --git a/projects/js-packages/charts/src/components/category-bar/category-bar.module.scss b/projects/js-packages/charts/src/components/category-bar/category-bar.module.scss new file mode 100644 index 0000000000000..75df43a86f981 --- /dev/null +++ b/projects/js-packages/charts/src/components/category-bar/category-bar.module.scss @@ -0,0 +1,95 @@ +.categoryBar { + display: flex; + flex-direction: column; + position: relative; + + &--empty { + display: block; + background-color: #e5e7eb; + border-radius: 4px; + } + + &__bar { + display: flex; + width: 100%; + position: relative; + overflow: visible; + } + + &__segment { + height: 100%; + flex-shrink: 0; + transition: opacity 0.15s ease; + cursor: default; + + &:hover { + opacity: 0.8; + } + } + + &__marker { + position: absolute; + width: 2px; + transform: translateX(-50%); + pointer-events: none; + z-index: 1; + + &--animated { + transition: left 0.5s ease-out; + } + + // Triangle indicator at top + &::before { + content: ""; + position: absolute; + top: -4px; + left: 50%; + transform: translateX(-50%); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid currentColor; + } + } + + &__labels { + display: flex; + position: relative; + margin-top: 4px; + height: 16px; + font-size: 12px; + color: #6b7280; + } + + &__label { + position: absolute; + transform: translateX(-50%); + white-space: nowrap; + + // First label aligned to left edge + &:first-child { + transform: translateX(0); + } + + // Last label aligned to right edge + &:last-child { + transform: translateX(-100%); + } + } + + &__tooltip { + background-color: #1f2937; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; + } + + &__tooltipLabel { + font-weight: 500; + } + + &__tooltipValue { + font-weight: 400; + } +} diff --git a/projects/js-packages/charts/src/components/category-bar/category-bar.tsx b/projects/js-packages/charts/src/components/category-bar/category-bar.tsx new file mode 100644 index 0000000000000..47ef7f805dfde --- /dev/null +++ b/projects/js-packages/charts/src/components/category-bar/category-bar.tsx @@ -0,0 +1,345 @@ +import { useParentSize } from '@visx/responsive'; +import { useTooltip, useTooltipInPortal } from '@visx/tooltip'; +import clsx from 'clsx'; +import { useMemo, forwardRef, useCallback, useContext } from 'react'; +import { + GlobalChartsProvider, + GlobalChartsContext, + useGlobalChartsTheme, + useChartId, +} from '../../providers'; +import styles from './category-bar.module.scss'; +import type { CategoryBarProps, CategoryBarSegment } from './types'; +import type { MouseEvent, FC } from 'react'; + +const DEFAULT_WIDTH = 300; +const DEFAULT_HEIGHT = 8; +const DEFAULT_GAP = 0; +const DEFAULT_BORDER_RADIUS = 4; +const DEFAULT_LABEL_FORMATTER = ( value: number ) => value.toString(); + +/** + * Normalizes input values to CategoryBarSegment array. + * @param values - Input values array (numbers or segment objects). + * @param colors - Optional custom colors array. + * @param themeColors - Theme colors to use as fallback. + * @return Normalized array of CategoryBarSegment objects. + */ +const normalizeSegments = ( + values: number[] | CategoryBarSegment[], + colors: string[] | undefined, + themeColors: string[] +): CategoryBarSegment[] => { + return ( values || [] ).map( ( value, index ) => { + const segment: CategoryBarSegment = typeof value === 'number' ? { value } : { ...value }; + + // Apply color priority: segment.color > colors prop > theme colors + if ( ! segment.color ) { + segment.color = colors?.[ index ] || themeColors[ index % themeColors.length ] || '#000000'; + } + + return segment; + } ); +}; + +const CategoryBarComponent = forwardRef< HTMLDivElement, CategoryBarProps >( + ( + { + values, + mode = 'proportional', + colors, + marker, + showLabels = true, + width = DEFAULT_WIDTH, + height = DEFAULT_HEIGHT, + gap = DEFAULT_GAP, + borderRadius = DEFAULT_BORDER_RADIUS, + className, + chartId: providedChartId, + labelFormatter = DEFAULT_LABEL_FORMATTER, + withTooltips = false, + }, + ref + ) => { + const theme = useGlobalChartsTheme(); + const generatedChartId = useChartId(); + const chartId = providedChartId || generatedChartId; + + const themeColors = useMemo( () => { + return theme?.colors || [ '#000000' ]; + }, [ theme ] ); + + // Normalize values to segments + const segments = useMemo( () => { + return normalizeSegments( values, colors, themeColors ); + }, [ values, colors, themeColors ] ); + + // Calculate total for proportional mode + const total = useMemo( () => { + return segments.reduce( ( sum, s ) => sum + s.value, 0 ); + }, [ segments ] ); + + // Calculate segment widths as percentages + const segmentWidths = useMemo( () => { + if ( segments.length === 0 ) { + return []; + } + if ( mode === 'equal' ) { + return segments.map( () => 100 / segments.length ); + } + if ( total === 0 ) { + return segments.map( () => 100 / segments.length ); + } + return segments.map( s => ( s.value / total ) * 100 ); + }, [ segments, mode, total ] ); + + // Calculate cumulative positions for labels + const labelPositions = useMemo( () => { + const positions: Array< { value: number; percent: number } > = [ { value: 0, percent: 0 } ]; + let cumulative = 0; + let cumulativePercent = 0; + + segments.forEach( ( s, index ) => { + cumulative += s.value; + cumulativePercent += segmentWidths[ index ]; + positions.push( { value: cumulative, percent: cumulativePercent } ); + } ); + + return positions; + }, [ segments, segmentWidths ] ); + + // Calculate marker position as percentage + const markerPosition = useMemo( () => { + if ( ! marker || total === 0 ) { + return null; + } + const percent = ( marker.value / total ) * 100; + return Math.min( 100, Math.max( 0, percent ) ); + }, [ marker, total ] ); + + // Tooltip handling + const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } = + useTooltip< { label?: string; value: number; color: string } >(); + + const { containerRef, TooltipInPortal } = useTooltipInPortal( { + detectBounds: true, + scroll: true, + } ); + + const createSegmentMouseMoveHandler = useCallback( + ( segment: CategoryBarSegment ) => ( event: MouseEvent< HTMLDivElement > ) => { + if ( ! withTooltips ) { + return; + } + const rect = ( event.currentTarget as HTMLElement ).getBoundingClientRect(); + showTooltip( { + tooltipData: { + label: segment.label, + value: segment.value, + color: segment.color || '#000', + }, + tooltipLeft: event.clientX - rect.left + rect.width / 2, + tooltipTop: -8, + } ); + }, + [ withTooltips, showTooltip ] + ); + + // Memoize handlers for each segment to avoid creating new functions on each render + const segmentMouseMoveHandlers = useMemo( () => { + return segments.map( segment => createSegmentMouseMoveHandler( segment ) ); + }, [ segments, createSegmentMouseMoveHandler ] ); + + const handleMouseLeave = useCallback( () => { + if ( withTooltips ) { + hideTooltip(); + } + }, [ withTooltips, hideTooltip ] ); + + // Handle empty data + if ( ! values || values.length === 0 ) { + return ( +
+ ); + } + + // Calculate total gap width + const totalGapWidth = gap * ( segments.length - 1 ); + const availableWidth = width - totalGapWidth; + + return ( +
+
+ { segments.map( ( segment, index ) => { + const widthPercent = segmentWidths[ index ]; + const isFirst = index === 0; + const isLast = index === segments.length - 1; + + return ( +
+ ); + } ) } + + { marker && markerPosition !== null && ( +
+ ) } +
+ + { showLabels && ( +
+ { labelPositions.map( ( pos, index ) => ( + + { labelFormatter( pos.value ) } + + ) ) } +
+ ) } + + { withTooltips && tooltipOpen && tooltipData && ( + +
+ { tooltipData.label && ( + { tooltipData.label }: + ) } + { tooltipData.value } +
+
+ ) } +
+ ); + } +); + +CategoryBarComponent.displayName = 'CategoryBarComponent'; + +/** + * CategoryBar chart component with GlobalChartsProvider wrapper. + * @param props - CategoryBar component props. + * @return CategoryBar component wrapped in provider if needed. + */ +const CategoryBarWithProvider: FC< CategoryBarProps > = props => { + const existingContext = useContext( GlobalChartsContext ); + + // If we're already in a GlobalChartsProvider context, don't create a new one + if ( existingContext ) { + return ; + } + + // Otherwise, create our own GlobalChartsProvider + return ( + + + + ); +}; + +CategoryBarWithProvider.displayName = 'CategoryBarUnresponsive'; + +// Export the provider-wrapped component as the unresponsive variant +const CategoryBarUnresponsive = CategoryBarWithProvider; + +/** + * Responsive configuration for CategoryBar + */ +export type CategoryBarResponsiveConfig = { + /** + * The maximum width of the chart. Defaults to 1200. + */ + maxWidth?: number; + /** + * Child render updates upon resize are delayed until debounceTime milliseconds after the last resize event. + */ + resizeDebounceTime?: number; +}; + +/** + * Responsive CategoryBar chart component. + * @param props - Component props including responsive configuration. + * @param props.resizeDebounceTime - Debounce time for resize events. + * @param props.maxWidth - Maximum width constraint. + * @return Responsive CategoryBar component. + */ +const CategoryBar = ( { + resizeDebounceTime = 300, + maxWidth = 1200, + ...chartProps +}: Omit< CategoryBarProps, 'width' > & CategoryBarResponsiveConfig & { width?: number } ) => { + const { parentRef, width: parentWidth } = useParentSize( { + debounceTime: resizeDebounceTime, + enableDebounceLeadingCall: true, + } ); + + const containerWidth = parentWidth > 0 ? Math.min( parentWidth, maxWidth ) : 0; + + return ( +
+ +
+ ); +}; + +CategoryBar.displayName = 'CategoryBar'; + +export { CategoryBar as default, CategoryBarUnresponsive }; From 8162e5c75851f4b175e5f14f40971b36ec8c81ed Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 09:38:42 +1100 Subject: [PATCH 03/17] Add CategoryBar component exports --- .../charts/src/components/category-bar/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 projects/js-packages/charts/src/components/category-bar/index.ts diff --git a/projects/js-packages/charts/src/components/category-bar/index.ts b/projects/js-packages/charts/src/components/category-bar/index.ts new file mode 100644 index 0000000000000..6ae3d013c5dc0 --- /dev/null +++ b/projects/js-packages/charts/src/components/category-bar/index.ts @@ -0,0 +1,9 @@ +export { default as CategoryBar, CategoryBarUnresponsive } from './category-bar'; +export type { CategoryBarResponsiveConfig } from './category-bar'; + +export type { + CategoryBarProps, + CategoryBarSegment, + CategoryBarMarker, + CategoryBarMode, +} from './types'; From 7298794f15f639189f3c3e1c1e72f9ea87ec35c4 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 09:38:53 +1100 Subject: [PATCH 04/17] Add CategoryBar component tests --- .../category-bar/test/category-bar.test.tsx | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 projects/js-packages/charts/src/components/category-bar/test/category-bar.test.tsx diff --git a/projects/js-packages/charts/src/components/category-bar/test/category-bar.test.tsx b/projects/js-packages/charts/src/components/category-bar/test/category-bar.test.tsx new file mode 100644 index 0000000000000..2182b0b194da1 --- /dev/null +++ b/projects/js-packages/charts/src/components/category-bar/test/category-bar.test.tsx @@ -0,0 +1,244 @@ +/** + * @jest-environment jsdom + */ + +import { render, screen } from '@testing-library/react'; +import { CategoryBar, CategoryBarUnresponsive } from '../'; +import { GlobalChartsProvider, jetpackTheme, wooTheme } from '../../../providers'; + +describe( 'CategoryBar', () => { + const defaultData = [ 25, 50, 25 ]; + + const renderWithTheme = ( props = {}, themeName = 'jetpack' ) => { + const theme = themeName === 'jetpack' ? jetpackTheme : wooTheme; + return render( + + + + ); + }; + + describe( 'Basic Rendering', () => { + test( 'renders with array of numbers', () => { + renderWithTheme(); + expect( screen.getByTestId( 'category-bar' ) ).toBeInTheDocument(); + } ); + + test( 'renders correct number of segments', () => { + renderWithTheme(); + expect( screen.getByTestId( 'category-bar-segment-0' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'category-bar-segment-1' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'category-bar-segment-2' ) ).toBeInTheDocument(); + } ); + + test( 'renders with segment objects', () => { + renderWithTheme( { + values: [ + { value: 30, label: 'First' }, + { value: 70, label: 'Second' }, + ], + } ); + expect( screen.getByTestId( 'category-bar-segment-0' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'category-bar-segment-1' ) ).toBeInTheDocument(); + } ); + + test( 'applies custom className', () => { + renderWithTheme( { className: 'custom-class' } ); + expect( screen.getByTestId( 'category-bar' ) ).toHaveClass( 'custom-class' ); + } ); + + test( 'renders responsive variant', () => { + render( + + + + ); + expect( screen.getByTestId( 'category-bar' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'Display Modes', () => { + test( 'proportional mode: segment widths reflect values', () => { + renderWithTheme( { values: [ 25, 75 ], width: 200, gap: 0 } ); + const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + const segment1 = screen.getByTestId( 'category-bar-segment-1' ); + + // First segment should be 25% width, second 75% + expect( segment0 ).toHaveStyle( { width: '50px' } ); // 25% of 200 + expect( segment1 ).toHaveStyle( { width: '150px' } ); // 75% of 200 + } ); + + test( 'equal mode: all segments same width', () => { + renderWithTheme( { values: [ 25, 75 ], mode: 'equal', width: 200, gap: 0 } ); + const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + const segment1 = screen.getByTestId( 'category-bar-segment-1' ); + + // Both segments should be 50% width + expect( segment0 ).toHaveStyle( { width: '100px' } ); + expect( segment1 ).toHaveStyle( { width: '100px' } ); + } ); + } ); + + describe( 'Edge Cases', () => { + test( 'handles empty values array', () => { + renderWithTheme( { values: [] } ); + expect( screen.getByTestId( 'category-bar-empty' ) ).toBeInTheDocument(); + } ); + + test( 'handles single segment', () => { + renderWithTheme( { values: [ 100 ] } ); + expect( screen.getByTestId( 'category-bar-segment-0' ) ).toBeInTheDocument(); + expect( screen.queryByTestId( 'category-bar-segment-1' ) ).not.toBeInTheDocument(); + } ); + + test( 'handles all zero values', () => { + renderWithTheme( { values: [ 0, 0, 0 ], mode: 'equal', width: 300, gap: 0 } ); + // Should render equal segments when all values are zero + const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + // Allow for floating point precision issues + const computedWidth = parseFloat( segment0.style.width ); + expect( computedWidth ).toBeCloseTo( 100, 0 ); + } ); + } ); + + describe( 'Styling', () => { + test( 'custom colors applied correctly', () => { + renderWithTheme( { + values: [ 50, 50 ], + colors: [ '#ff0000', '#00ff00' ], + } ); + const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + const segment1 = screen.getByTestId( 'category-bar-segment-1' ); + + expect( segment0 ).toHaveStyle( { backgroundColor: '#ff0000' } ); + expect( segment1 ).toHaveStyle( { backgroundColor: '#00ff00' } ); + } ); + + test( 'segment object colors override colors prop', () => { + renderWithTheme( { + values: [ { value: 50, color: '#0000ff' }, { value: 50 } ], + colors: [ '#ff0000', '#00ff00' ], + } ); + const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + const segment1 = screen.getByTestId( 'category-bar-segment-1' ); + + expect( segment0 ).toHaveStyle( { backgroundColor: '#0000ff' } ); + expect( segment1 ).toHaveStyle( { backgroundColor: '#00ff00' } ); + } ); + + test( 'border radius applied to first and last segments', () => { + renderWithTheme( { values: [ 33, 34, 33 ], borderRadius: 8 } ); + const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + const segment2 = screen.getByTestId( 'category-bar-segment-2' ); + + expect( segment0 ).toHaveStyle( { borderTopLeftRadius: '8px' } ); + expect( segment0 ).toHaveStyle( { borderBottomLeftRadius: '8px' } ); + expect( segment2 ).toHaveStyle( { borderTopRightRadius: '8px' } ); + expect( segment2 ).toHaveStyle( { borderBottomRightRadius: '8px' } ); + } ); + } ); + + describe( 'Dimensions', () => { + test( 'applies default dimensions', () => { + renderWithTheme(); + const bar = screen.getByTestId( 'category-bar' ); + expect( bar ).toHaveStyle( { width: '300px' } ); + } ); + + test( 'applies custom dimensions', () => { + renderWithTheme( { width: 400, height: 12 } ); + const bar = screen.getByTestId( 'category-bar' ); + expect( bar ).toHaveStyle( { width: '400px' } ); + } ); + } ); + + describe( 'Labels', () => { + test( 'shows labels when showLabels=true', () => { + renderWithTheme( { showLabels: true } ); + // Should show cumulative labels: 0, 25, 75, 100 + expect( screen.getByTestId( 'category-bar-label-0' ) ).toHaveTextContent( '0' ); + expect( screen.getByTestId( 'category-bar-label-3' ) ).toHaveTextContent( '100' ); + } ); + + test( 'hides labels when showLabels=false', () => { + renderWithTheme( { showLabels: false } ); + expect( screen.queryByTestId( 'category-bar-label-0' ) ).not.toBeInTheDocument(); + } ); + + test( 'custom labelFormatter works', () => { + renderWithTheme( { + showLabels: true, + labelFormatter: value => `${ value }%`, + } ); + expect( screen.getByTestId( 'category-bar-label-0' ) ).toHaveTextContent( '0%' ); + expect( screen.getByTestId( 'category-bar-label-3' ) ).toHaveTextContent( '100%' ); + } ); + } ); + + describe( 'Marker', () => { + test( 'renders marker at correct position', () => { + renderWithTheme( { + values: [ 50, 50 ], + marker: { value: 25 }, + } ); + const marker = screen.getByTestId( 'category-bar-marker' ); + expect( marker ).toBeInTheDocument(); + expect( marker ).toHaveStyle( { left: '25%' } ); + } ); + + test( 'marker tooltip displays', () => { + renderWithTheme( { + values: [ 50, 50 ], + marker: { value: 50, tooltip: 'Halfway point' }, + } ); + const marker = screen.getByTestId( 'category-bar-marker' ); + expect( marker ).toHaveAttribute( 'title', 'Halfway point' ); + } ); + + test( 'marker renders with showAnimation=true', () => { + renderWithTheme( { + values: [ 50, 50 ], + marker: { value: 50, showAnimation: true }, + } ); + const marker = screen.getByTestId( 'category-bar-marker' ); + // Marker should be present - animation is a CSS-only visual effect + expect( marker ).toBeInTheDocument(); + expect( marker ).toHaveStyle( { left: '50%' } ); + } ); + } ); + + describe( 'Theme Integration', () => { + test( 'uses jetpack theme colors', () => { + renderWithTheme( { values: [ 50, 50 ] }, 'jetpack' ); + const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + // Jetpack theme first color + expect( segment0 ).toHaveStyle( { backgroundColor: jetpackTheme.colors[ 0 ] } ); + } ); + + test( 'uses woo theme colors', () => { + renderWithTheme( { values: [ 50, 50 ] }, 'woo' ); + const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + // Woo theme first color + expect( segment0 ).toHaveStyle( { backgroundColor: wooTheme.colors[ 0 ] } ); + } ); + + test( 'color prop overrides theme color', () => { + renderWithTheme( { + values: [ 50, 50 ], + colors: [ '#custom1', '#custom2' ], + } ); + const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + expect( segment0 ).toHaveStyle( { backgroundColor: '#custom1' } ); + } ); + } ); + + describe( 'Gap Between Segments', () => { + test( 'applies gap between segments', () => { + renderWithTheme( { values: [ 50, 50 ], gap: 4, width: 204 } ); + // With 4px gap and 204px width, available width is 200px + // Each segment should be 100px (50% of 200) + const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + expect( segment0 ).toHaveStyle( { width: '100px' } ); + } ); + } ); +} ); From 44fcce8b9d81f363497d18078809b50e188a487a Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 09:39:04 +1100 Subject: [PATCH 05/17] Add CategoryBar Storybook stories --- .../category-bar/stories/index.stories.tsx | 399 ++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 projects/js-packages/charts/src/components/category-bar/stories/index.stories.tsx diff --git a/projects/js-packages/charts/src/components/category-bar/stories/index.stories.tsx b/projects/js-packages/charts/src/components/category-bar/stories/index.stories.tsx new file mode 100644 index 0000000000000..5dfe8925d803a --- /dev/null +++ b/projects/js-packages/charts/src/components/category-bar/stories/index.stories.tsx @@ -0,0 +1,399 @@ +import { CategoryBar, CategoryBarUnresponsive } from '../'; +import type { CategoryBarProps } from '../types'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta< CategoryBarProps > = { + title: 'JS Packages/Charts/Types/Category Bar', + component: CategoryBar, + parameters: { + layout: 'padded', + }, + argTypes: { + values: { + control: 'object', + description: 'Array of segment values (numbers or segment objects)', + table: { category: 'Data' }, + }, + mode: { + control: 'select', + options: [ 'proportional', 'equal' ], + description: 'How segment widths are calculated', + table: { category: 'Display' }, + }, + colors: { + control: 'object', + description: 'Custom colors for segments', + table: { category: 'Visual Style' }, + }, + showLabels: { + control: 'boolean', + description: 'Whether to show cumulative labels', + table: { category: 'Display' }, + }, + width: { + control: { type: 'number', min: 100, max: 600 }, + description: 'Width of the bar in pixels', + table: { category: 'Dimensions' }, + }, + height: { + control: { type: 'number', min: 4, max: 32 }, + description: 'Height of the bar in pixels', + table: { category: 'Dimensions' }, + }, + gap: { + control: { type: 'number', min: 0, max: 8 }, + description: 'Gap between segments in pixels', + table: { category: 'Visual Style' }, + }, + borderRadius: { + control: { type: 'number', min: 0, max: 16 }, + description: 'Corner radius for the bar', + table: { category: 'Visual Style' }, + }, + withTooltips: { + control: 'boolean', + description: 'Whether to show tooltips on hover', + table: { category: 'Interaction' }, + }, + }, +}; + +export default meta; +type Story = StoryObj< typeof CategoryBar >; + +export const Default: Story = { + args: { + values: [ 10, 25, 45, 20 ], + width: 400, + height: 8, + }, +}; + +export const ProportionalMode: Story = { + args: { + values: [ 25, 50, 25 ], + mode: 'proportional', + width: 400, + height: 8, + }, + parameters: { + docs: { + description: { + story: + 'In proportional mode (default), segment widths are based on their values relative to the total.', + }, + }, + }, +}; + +export const EqualMode: Story = { + args: { + values: [ 10, 20, 30 ], + mode: 'equal', + width: 400, + height: 8, + colors: [ '#ef4444', '#f59e0b', '#22c55e' ], + }, + parameters: { + docs: { + description: { + story: + 'In equal mode, all segments have the same width regardless of their values. Useful for status indicators.', + }, + }, + }, +}; + +export const WithMarker: Story = { + args: { + values: [ 10, 25, 45, 20 ], + width: 400, + height: 8, + marker: { + value: 68, + tooltip: 'Current: 68%', + showAnimation: true, + }, + }, + parameters: { + docs: { + description: { + story: 'A marker can be added to indicate a specific position on the bar.', + }, + }, + }, +}; + +export const CustomColors: Story = { + args: { + values: [ 25, 25, 25, 25 ], + width: 400, + height: 8, + colors: [ '#8b5cf6', '#ec4899', '#f97316', '#14b8a6' ], + }, + parameters: { + docs: { + description: { + story: 'Custom colors can be applied to segments using the colors prop.', + }, + }, + }, +}; + +export const HealthCheckStyle: Story = { + args: { + values: [ + { value: 1, label: 'Critical' }, + { value: 1, label: 'Warning' }, + { value: 1, label: 'Good' }, + ], + mode: 'equal', + width: 300, + height: 6, + colors: [ '#ef4444', '#f59e0b', '#22c55e' ], + showLabels: false, + gap: 2, + borderRadius: 3, + }, + parameters: { + docs: { + description: { + story: + 'A health-check style indicator with equal segments and semantic colors (red/yellow/green).', + }, + }, + }, +}; + +export const ProgressIndicator: Story = { + args: { + values: [ 65, 35 ], + width: 300, + height: 10, + colors: [ '#3b82f6', '#e5e7eb' ], + showLabels: false, + borderRadius: 5, + }, + parameters: { + docs: { + description: { + story: 'A simple two-segment progress indicator showing completion percentage.', + }, + }, + }, +}; + +export const NoLabels: Story = { + args: { + values: [ 30, 45, 25 ], + width: 400, + height: 8, + showLabels: false, + }, + parameters: { + docs: { + description: { + story: 'The cumulative labels can be hidden for a cleaner appearance.', + }, + }, + }, +}; + +export const WithTooltips: Story = { + args: { + values: [ + { value: 30, label: 'Product A' }, + { value: 45, label: 'Product B' }, + { value: 25, label: 'Product C' }, + ], + width: 400, + height: 12, + withTooltips: true, + }, + parameters: { + docs: { + description: { + story: 'Hover over segments to see tooltip details when withTooltips is enabled.', + }, + }, + }, +}; + +export const WithSegmentObjects: Story = { + args: { + values: [ + { value: 40, label: 'Revenue', color: '#22c55e' }, + { value: 35, label: 'Costs', color: '#ef4444' }, + { value: 25, label: 'Profit', color: '#3b82f6' }, + ], + width: 400, + height: 10, + withTooltips: true, + }, + parameters: { + docs: { + description: { + story: 'Segment objects allow specifying individual labels and colors for each segment.', + }, + }, + }, +}; + +export const CustomLabelFormatter: Story = { + args: { + values: [ 25, 50, 25 ], + width: 400, + height: 8, + labelFormatter: value => `${ value }%`, + }, + parameters: { + docs: { + description: { + story: 'A custom label formatter can be used to format the cumulative value labels.', + }, + }, + }, +}; + +export const WithGap: Story = { + args: { + values: [ 20, 30, 25, 25 ], + width: 400, + height: 10, + gap: 4, + borderRadius: 4, + }, + parameters: { + docs: { + description: { + story: 'Gaps can be added between segments for visual separation.', + }, + }, + }, +}; + +export const DashboardIntegration: Story = { + render: () => ( +
+
+
+ Monthly OpEx + $180k +
+ +
+
+
+ Budget Utilization + 72% +
+ +
+
+ ), + parameters: { + docs: { + description: { + story: 'Example of CategoryBar integrated into a dashboard card layout.', + }, + }, + }, +}; + +export const ComparisonStack: Story = { + render: () => ( +
+ { [ 'Jan', 'Feb', 'Mar', 'Apr' ].map( ( month, index ) => ( +
+
{ month }
+ +
+ ) ) } +
+ ), + parameters: { + docs: { + description: { + story: 'Multiple CategoryBars stacked vertically to show comparison over time.', + }, + }, + }, +}; + +export const Responsive: Story = { + args: { + values: [ 30, 40, 30 ], + height: 10, + showLabels: true, + }, + parameters: { + docs: { + description: { + story: + 'The responsive variant adapts to container width. Resize the container to see the chart adapt.', + }, + }, + }, +}; + +export const EdgeCases: Story = { + render: () => ( +
+
+

Empty Data

+ +
+
+

Single Segment

+ +
+
+

Many Segments

+ +
+
+

Very Small Values

+ +
+
+ ), + parameters: { + docs: { + description: { + story: 'Examples of how the CategoryBar handles edge cases and unusual data.', + }, + }, + }, +}; From e8320a85339c37797f820c7c5b97866dcc569bca Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 09:39:15 +1100 Subject: [PATCH 06/17] Add CategoryBar component documentation --- .../category-bar/stories/index.api.mdx | 261 +++++++++++++++++ .../category-bar/stories/index.docs.mdx | 274 ++++++++++++++++++ 2 files changed, 535 insertions(+) create mode 100644 projects/js-packages/charts/src/components/category-bar/stories/index.api.mdx create mode 100644 projects/js-packages/charts/src/components/category-bar/stories/index.docs.mdx diff --git a/projects/js-packages/charts/src/components/category-bar/stories/index.api.mdx b/projects/js-packages/charts/src/components/category-bar/stories/index.api.mdx new file mode 100644 index 0000000000000..0a4c0512e8dc8 --- /dev/null +++ b/projects/js-packages/charts/src/components/category-bar/stories/index.api.mdx @@ -0,0 +1,261 @@ +import { Meta, Source } from '@storybook/addon-docs/blocks'; + + + +# Category Bar API Reference + +Complete API documentation for the Category Bar component. + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `values` | `number[] \| CategoryBarSegment[]` | Required | Array of segment values or segment objects | +| `mode` | `'proportional' \| 'equal'` | `'proportional'` | How segment widths are calculated | +| `colors` | `string[]` | Theme colors | Custom colors for segments | +| `marker` | `CategoryBarMarker` | - | Optional position indicator | +| `showLabels` | `boolean` | `true` | Whether to show cumulative value labels | +| `width` | `number` | `300` | Width of the bar in pixels | +| `height` | `number` | `8` | Height of the bar in pixels | +| `size` | `number` | - | Size for responsive variant | +| `gap` | `number` | `0` | Gap between segments in pixels | +| `borderRadius` | `number` | `4` | Corner radius for bar ends | +| `className` | `string` | - | Additional CSS class name | +| `chartId` | `string` | Auto-generated | Unique ID for the chart | +| `labelFormatter` | `(value: number) => string` | `value.toString()` | Formatter for label values | +| `withTooltips` | `boolean` | `false` | Whether to show tooltips on hover | + +## Type Definitions + +### CategoryBarSegment + + + +### CategoryBarMarker + + + +### CategoryBarMode + + + +### CategoryBarProps + + string; + withTooltips?: boolean; +}`} +/> + +## Exports + + + +## Component Variants + +### CategoryBar (Responsive) + +The default export that automatically adapts to container width: + +`} +/> + +### CategoryBarUnresponsive + +Fixed-size variant that requires explicit width: + +`} +/> + +## CSS Classes + +The component uses CSS Modules with the following class structure: + +| Class | Description | +|-------|-------------| +| `.categoryBar` | Root container element | +| `.categoryBar--empty` | Applied when values array is empty | +| `.categoryBar__bar` | The horizontal bar container | +| `.categoryBar__segment` | Individual segment elements | +| `.categoryBar__segment--first` | First segment (has left border radius) | +| `.categoryBar__segment--last` | Last segment (has right border radius) | +| `.categoryBar__marker` | Position marker element | +| `.categoryBar__marker--animated` | Marker with animation enabled | +| `.categoryBar__labels` | Labels container | +| `.categoryBar__label` | Individual label elements | +| `.categoryBar__tooltip` | Tooltip container | +| `.categoryBar__tooltipLabel` | Tooltip label text | +| `.categoryBar__tooltipValue` | Tooltip value text | + +## Display Modes + +### Proportional Mode + +Segment widths are calculated as a percentage of the total: + +``` +Segment width = (segment.value / total) * 100% +``` + +Example with values `[25, 75]`: +- Segment 1: 25% +- Segment 2: 75% + +### Equal Mode + +All segments have equal width regardless of values: + +``` +Segment width = 100% / number of segments +``` + +Example with values `[10, 20, 30]`: +- All segments: 33.33% + +## Marker Positioning + +The marker position is calculated as a percentage of the total: + +``` +Marker position = (marker.value / total) * 100% +``` + +For a bar with values `[25, 50, 25]` (total = 100): +- `marker.value: 25` → positioned at 25% +- `marker.value: 75` → positioned at 75% + +## Theme Integration + +The component uses `GlobalChartsProvider` for theming: + + + +`} +/> + +### Color Priority + +Colors are applied in this priority order: + +1. `segment.color` (from segment object) +2. `colors[index]` (from colors prop) +3. `theme.colors[index]` (from theme) + +## Edge Case Handling + +| Scenario | Behavior | +|----------|----------| +| Empty `values` array | Renders empty placeholder with `data-testid="category-bar-empty"` | +| Single segment | Renders full-width bar | +| All zero values | Equal mode: equal segments; Proportional: equal segments | +| Marker value > total | Marker clamped to 100% | +| Marker value < 0 | Marker clamped to 0% | + +## Data Test IDs + +For testing, the component provides these test IDs: + +| Test ID | Description | +|---------|-------------| +| `category-bar` | Main component container | +| `category-bar-empty` | Empty state container | +| `category-bar-segment-{index}` | Individual segments (0-indexed) | +| `category-bar-marker` | Position marker | +| `category-bar-label-{index}` | Cumulative labels (0-indexed) | + +## Performance Notes + +- Segment colors and widths are memoized for performance +- The responsive variant uses `ResizeObserver` for efficient resizing +- Tooltip portal is only rendered when `withTooltips` is enabled diff --git a/projects/js-packages/charts/src/components/category-bar/stories/index.docs.mdx b/projects/js-packages/charts/src/components/category-bar/stories/index.docs.mdx new file mode 100644 index 0000000000000..c24a9c6de7e69 --- /dev/null +++ b/projects/js-packages/charts/src/components/category-bar/stories/index.docs.mdx @@ -0,0 +1,274 @@ +import { Meta, Canvas, Story, Source } from '@storybook/addon-docs/blocks'; +import * as CategoryBarStories from './index.stories'; + + + +# Category Bar + +The Category Bar is a horizontal segmented bar component for visualizing proportions, progress, performance, or status. It displays colored segments that can show proportional data or equal-width status indicators. + + + +## Overview + +The Category Bar component provides a flexible way to display proportional data in a horizontal format. It supports two display modes, optional markers, cumulative labels, and full theme integration. + +`} +/> + +## API Reference + +For detailed information about component props, types, and exports, see the [Category Bar API Reference](./?path=/docs/js-packages-charts-types-category-bar-api-reference--docs). + +## Basic Usage + +### Simple Category Bar + +The simplest category bar requires only a `values` prop with an array of numbers: + + + +`} +/> + +### Required Props + +- **`values`**: Array of numbers or segment objects containing value, label, and optional color + +### Optional Props + +- **`mode`** (default: `'proportional'`): Display mode - `'proportional'` or `'equal'` +- **`width`** (default: `300`): Width of the bar in pixels +- **`height`** (default: `8`): Height of the bar in pixels +- **`colors`**: Custom colors for segments +- **`showLabels`** (default: `true`): Whether to show cumulative value labels +- **`marker`**: Optional position indicator +- **`gap`** (default: `0`): Gap between segments in pixels +- **`borderRadius`** (default: `4`): Corner radius for the bar +- **`withTooltips`** (default: `false`): Enables interactive tooltips on hover + +## Display Modes + +### Proportional Mode (Default) + +In proportional mode, segment widths are calculated based on their values relative to the total: + + + +`} +/> + +### Equal Mode + +In equal mode, all segments have the same width regardless of their values. This is useful for status indicators: + + + +`} +/> + +## Marker Indicator + +Add a marker to indicate a specific position on the bar: + + + +`} +/> + +## Color Customization + +### Using Colors Prop + +Apply custom colors to segments: + + + +`} +/> + +### Using Segment Objects + +For more control, use segment objects with individual colors: + + + +`} +/> + +## Common Use Cases + +### Health Check Indicator + + + +`} +/> + +### Progress Indicator + + + +`} +/> + +## Dashboard Integration + +The Category Bar works well in dashboard layouts: + + + +## Labels and Formatting + +### Custom Label Formatter + +Use a custom formatter for cumulative labels: + + + + \`\${value}%\`} + width={400} +/>`} +/> + +### Hiding Labels + +Labels can be hidden for a cleaner appearance: + + + +## Visual Customization + +### Adding Gaps Between Segments + + + +`} +/> + +## Theme Integration + +The Category Bar automatically uses colors from the theme provided by `GlobalChartsProvider`: + + + +`} +/> + +Custom colors via the `colors` prop or segment objects will override theme colors. + +## Edge Cases + +The Category Bar handles various edge cases gracefully: + + + +- **Empty data**: Renders an empty placeholder +- **Single segment**: Displays a full-width bar +- **Many segments**: Handles numerous segments proportionally +- **Very small values**: Maintains visibility for tiny segments + +## Accessibility + +The Category Bar component: + +- Uses semantic HTML structure +- Supports tooltip descriptions for segment details +- Provides data-testid attributes for testing +- Maintains sufficient color contrast + +## Browser Compatibility + +The Category Bar uses standard CSS and HTML and is compatible with all modern browsers. From 16da37546ad0ff207a2a14a97dd477ab1f54bca5 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 09:39:20 +1100 Subject: [PATCH 07/17] Export CategoryBar from main package entry --- projects/js-packages/charts/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/js-packages/charts/src/index.ts b/projects/js-packages/charts/src/index.ts index dcb780bd88ef7..16492889a2d0c 100644 --- a/projects/js-packages/charts/src/index.ts +++ b/projects/js-packages/charts/src/index.ts @@ -9,6 +9,7 @@ export { export { BarListChart, BarListChartUnresponsive } from './components/bar-list-chart'; export { LeaderboardChart, LeaderboardChartUnresponsive } from './components/leaderboard-chart'; export { ConversionFunnelChart } from './components/conversion-funnel-chart'; +export { CategoryBar, CategoryBarUnresponsive } from './components/category-bar'; // Chart components export { BaseTooltip } from './components/tooltip'; From 348f03c9364295546622f93a8c1b92f6b9a08c33 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 09:39:30 +1100 Subject: [PATCH 08/17] Add CategoryBar export paths to package.json --- projects/js-packages/charts/package.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/projects/js-packages/charts/package.json b/projects/js-packages/charts/package.json index 174248a7ef399..c4d3e02753063 100644 --- a/projects/js-packages/charts/package.json +++ b/projects/js-packages/charts/package.json @@ -36,6 +36,12 @@ "require": "./dist/components/bar-list-chart/index.cjs" }, "./bar-list-chart/style.css": "./dist/components/bar-list-chart/index.css", + "./category-bar": { + "jetpack:src": "./src/components/category-bar/index.ts", + "import": "./dist/components/category-bar/index.js", + "require": "./dist/components/category-bar/index.cjs" + }, + "./category-bar/style.css": "./dist/components/category-bar/index.css", "./conversion-funnel-chart": { "jetpack:src": "./src/components/conversion-funnel-chart/index.ts", "import": "./dist/components/conversion-funnel-chart/index.js", @@ -132,6 +138,9 @@ "bar-list-chart": [ "./dist/components/bar-list-chart/index.d.ts" ], + "category-bar": [ + "./dist/components/category-bar/index.d.ts" + ], "leaderboard-chart": [ "./dist/components/leaderboard-chart/index.d.ts" ], From 0090c7ca140cea62b389d2754a061d4ae160a26a Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 15:23:06 +1100 Subject: [PATCH 09/17] Add stacked prop support to BarChart component --- .../src/components/bar-chart/bar-chart.tsx | 71 +++++++++++++------ 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx b/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx index b30f311b8d9b4..f93631bc2377c 100644 --- a/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx +++ b/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx @@ -1,6 +1,6 @@ import { formatNumber } from '@automattic/number-formatters'; import { PatternLines, PatternCircles, PatternWaves, PatternHexagons } from '@visx/pattern'; -import { Axis, BarSeries, BarGroup, Grid, XYChart } from '@visx/xychart'; +import { Axis, BarSeries, BarGroup, BarStack, Grid, XYChart } from '@visx/xychart'; import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; import { useCallback, useContext, useState, useRef, useMemo } from 'react'; @@ -39,6 +39,11 @@ export interface BarChartProps extends BaseChartProps< SeriesData[] > { showZeroValues?: boolean; legendInteractive?: boolean; children?: ReactNode; + /** + * When true, bars are stacked on top of each other instead of grouped side by side. + * Works with both horizontal and vertical orientations. + */ + stacked?: boolean; } // Base props type with optional responsive properties @@ -99,6 +104,7 @@ const BarChartInternal: FC< BarChartProps > = ( { legendInteractive = false, animation, children, + stacked = false, } ) => { const horizontal = orientation === 'horizontal'; const chartId = useChartId( providedChartId ); @@ -290,8 +296,9 @@ const BarChartInternal: FC< BarChartProps > = ( { () => ( { orientation, withPatterns, + stacked, } ), - [ orientation, withPatterns ] + [ orientation, withPatterns, stacked ] ); // Register chart with context only if data is valid @@ -400,25 +407,47 @@ const BarChartInternal: FC< BarChartProps > = ( { ) : null } - - { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => { - // Skip rendering invisible series - if ( ! isVisible ) { - return null; - } - - return ( - - ); - } ) } - + { stacked ? ( + + { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => { + // Skip rendering invisible series + if ( ! isVisible ) { + return null; + } + + return ( + + ); + } ) } + + ) : ( + + { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => { + // Skip rendering invisible series + if ( ! isVisible ) { + return null; + } + + return ( + + ); + } ) } + + ) } From 566d5f61f2354ec8a5e4b79e20f617441ca4e36b Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 15:23:15 +1100 Subject: [PATCH 10/17] Add Storybook stories for stacked bar charts --- .../bar-chart/stories/index.stories.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/projects/js-packages/charts/src/components/bar-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/components/bar-chart/stories/index.stories.tsx index d16650e9c3079..243e93afdae9b 100644 --- a/projects/js-packages/charts/src/components/bar-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/components/bar-chart/stories/index.stories.tsx @@ -55,6 +55,11 @@ const meta: Meta< StoryArgs > = { description: 'Use patterns for bars', table: { category: 'Visual Style' }, }, + stacked: { + control: 'boolean', + description: 'Stack bars on top of each other instead of grouping side by side', + table: { category: 'Visual Style' }, + }, }, render: args => { const { seriesCount, ...chartProps } = args; @@ -340,6 +345,43 @@ export const HorizontalBarChart: Story = { }, }; +// Stacked bar chart stories +export const StackedVertical: Story = { + args: { + ...Default.args, + data: [ medalCountsData[ 0 ], medalCountsData[ 1 ], medalCountsData[ 2 ] ], + stacked: true, + showLegend: true, + }, + parameters: { + docs: { + description: { + story: + 'Vertical stacked bar chart where bars are stacked on top of each other instead of grouped side by side. Useful for showing part-to-whole relationships across categories.', + }, + }, + }, +}; + +export const StackedHorizontal: Story = { + args: { + ...Default.args, + data: [ medalCountsData[ 0 ], medalCountsData[ 1 ], medalCountsData[ 2 ] ], + stacked: true, + orientation: 'horizontal', + showLegend: true, + gridVisibility: 'y', + }, + parameters: { + docs: { + description: { + story: + 'Horizontal stacked bar chart. Combines the stacked layout with horizontal orientation for a different visual presentation of the same data.', + }, + }, + }, +}; + const dataWithZeroValues = [ { group: 'United States', From 6c40babc8fedd7bb2f49811238a588c47cec16e4 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 15:23:22 +1100 Subject: [PATCH 11/17] Add stacked prop to BarChart API documentation --- .../charts/src/components/bar-chart/stories/index.api.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/js-packages/charts/src/components/bar-chart/stories/index.api.mdx b/projects/js-packages/charts/src/components/bar-chart/stories/index.api.mdx index cc73a1c20df17..723f6a7afca5b 100644 --- a/projects/js-packages/charts/src/components/bar-chart/stories/index.api.mdx +++ b/projects/js-packages/charts/src/components/bar-chart/stories/index.api.mdx @@ -17,6 +17,7 @@ Main chart component with responsive behavior by default. | `width` | `number` | responsive | Chart width in pixels | | `height` | `number` | `400` | Chart height in pixels | | `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Bar orientation | +| `stacked` | `boolean` | `false` | Stack bars on top of each other instead of grouped side by side | | `withTooltips` | `boolean` | `false` | Enable interactive tooltips | | `withPatterns` | `boolean` | `false` | Use pattern fills for accessibility | | `showZeroValues` | `boolean` | `false` | Display zero values with minimum visual height | From 90981630ee82bcb37d13ffc2fd54157bc65eca83 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 15:26:22 +1100 Subject: [PATCH 12/17] changelog --- .../js-packages/charts/changelog/add-new-chart-categorybar | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 projects/js-packages/charts/changelog/add-new-chart-categorybar diff --git a/projects/js-packages/charts/changelog/add-new-chart-categorybar b/projects/js-packages/charts/changelog/add-new-chart-categorybar new file mode 100644 index 0000000000000..c52b57b741af4 --- /dev/null +++ b/projects/js-packages/charts/changelog/add-new-chart-categorybar @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Charts: adds a category bar chart From 5498286c906a522ac0c5fc17d8634bfb8c2e586b Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 15:52:19 +1100 Subject: [PATCH 13/17] Simplify CategoryBar stories to essential examples --- .../category-bar/stories/index.stories.tsx | 353 ++---------------- 1 file changed, 32 insertions(+), 321 deletions(-) diff --git a/projects/js-packages/charts/src/components/category-bar/stories/index.stories.tsx b/projects/js-packages/charts/src/components/category-bar/stories/index.stories.tsx index 5dfe8925d803a..db9c8cc429d4c 100644 --- a/projects/js-packages/charts/src/components/category-bar/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/components/category-bar/stories/index.stories.tsx @@ -1,4 +1,4 @@ -import { CategoryBar, CategoryBarUnresponsive } from '../'; +import { CategoryBar } from '../'; import type { CategoryBarProps } from '../types'; import type { Meta, StoryObj } from '@storybook/react'; @@ -11,13 +11,13 @@ const meta: Meta< CategoryBarProps > = { argTypes: { values: { control: 'object', - description: 'Array of segment values (numbers or segment objects)', + description: 'Array of segment values (numbers or segment objects with value, label, color)', table: { category: 'Data' }, }, mode: { control: 'select', options: [ 'proportional', 'equal' ], - description: 'How segment widths are calculated', + description: 'Proportional: widths based on values. Equal: all segments same width.', table: { category: 'Display' }, }, colors: { @@ -27,13 +27,13 @@ const meta: Meta< CategoryBarProps > = { }, showLabels: { control: 'boolean', - description: 'Whether to show cumulative labels', + description: 'Show cumulative percentage labels', table: { category: 'Display' }, }, - width: { - control: { type: 'number', min: 100, max: 600 }, - description: 'Width of the bar in pixels', - table: { category: 'Dimensions' }, + withTooltips: { + control: 'boolean', + description: 'Show tooltips on hover', + table: { category: 'Interaction' }, }, height: { control: { type: 'number', min: 4, max: 32 }, @@ -50,97 +50,34 @@ const meta: Meta< CategoryBarProps > = { description: 'Corner radius for the bar', table: { category: 'Visual Style' }, }, - withTooltips: { - control: 'boolean', - description: 'Whether to show tooltips on hover', - table: { category: 'Interaction' }, - }, }, }; export default meta; type Story = StoryObj< typeof CategoryBar >; +/** + * Default category bar with proportional segments. + * Use the controls to explore different configurations. + */ export const Default: Story = { args: { - values: [ 10, 25, 45, 20 ], - width: 400, - height: 8, - }, -}; - -export const ProportionalMode: Story = { - args: { - values: [ 25, 50, 25 ], - mode: 'proportional', - width: 400, + values: [ + { value: 40, label: 'Direct' }, + { value: 30, label: 'Organic Search' }, + { value: 20, label: 'Referral' }, + { value: 10, label: 'Social' }, + ], height: 8, - }, - parameters: { - docs: { - description: { - story: - 'In proportional mode (default), segment widths are based on their values relative to the total.', - }, - }, + withTooltips: true, }, }; +/** + * Equal mode where all segments have the same width regardless of values. + * Useful for status indicators like health checks. + */ export const EqualMode: Story = { - args: { - values: [ 10, 20, 30 ], - mode: 'equal', - width: 400, - height: 8, - colors: [ '#ef4444', '#f59e0b', '#22c55e' ], - }, - parameters: { - docs: { - description: { - story: - 'In equal mode, all segments have the same width regardless of their values. Useful for status indicators.', - }, - }, - }, -}; - -export const WithMarker: Story = { - args: { - values: [ 10, 25, 45, 20 ], - width: 400, - height: 8, - marker: { - value: 68, - tooltip: 'Current: 68%', - showAnimation: true, - }, - }, - parameters: { - docs: { - description: { - story: 'A marker can be added to indicate a specific position on the bar.', - }, - }, - }, -}; - -export const CustomColors: Story = { - args: { - values: [ 25, 25, 25, 25 ], - width: 400, - height: 8, - colors: [ '#8b5cf6', '#ec4899', '#f97316', '#14b8a6' ], - }, - parameters: { - docs: { - description: { - story: 'Custom colors can be applied to segments using the colors prop.', - }, - }, - }, -}; - -export const HealthCheckStyle: Story = { args: { values: [ { value: 1, label: 'Critical' }, @@ -148,252 +85,26 @@ export const HealthCheckStyle: Story = { { value: 1, label: 'Good' }, ], mode: 'equal', - width: 300, height: 6, colors: [ '#ef4444', '#f59e0b', '#22c55e' ], - showLabels: false, gap: 2, borderRadius: 3, - }, - parameters: { - docs: { - description: { - story: - 'A health-check style indicator with equal segments and semantic colors (red/yellow/green).', - }, - }, - }, -}; - -export const ProgressIndicator: Story = { - args: { - values: [ 65, 35 ], - width: 300, - height: 10, - colors: [ '#3b82f6', '#e5e7eb' ], showLabels: false, - borderRadius: 5, - }, - parameters: { - docs: { - description: { - story: 'A simple two-segment progress indicator showing completion percentage.', - }, - }, }, }; -export const NoLabels: Story = { +/** + * Category bar with a marker indicating a specific position. + */ +export const WithMarker: Story = { args: { - values: [ 30, 45, 25 ], - width: 400, + values: [ 25, 35, 25, 15 ], height: 8, - showLabels: false, - }, - parameters: { - docs: { - description: { - story: 'The cumulative labels can be hidden for a cleaner appearance.', - }, + marker: { + value: 60, + tooltip: 'Target: 60%', + showAnimation: true, }, - }, -}; - -export const WithTooltips: Story = { - args: { - values: [ - { value: 30, label: 'Product A' }, - { value: 45, label: 'Product B' }, - { value: 25, label: 'Product C' }, - ], - width: 400, - height: 12, withTooltips: true, }, - parameters: { - docs: { - description: { - story: 'Hover over segments to see tooltip details when withTooltips is enabled.', - }, - }, - }, -}; - -export const WithSegmentObjects: Story = { - args: { - values: [ - { value: 40, label: 'Revenue', color: '#22c55e' }, - { value: 35, label: 'Costs', color: '#ef4444' }, - { value: 25, label: 'Profit', color: '#3b82f6' }, - ], - width: 400, - height: 10, - withTooltips: true, - }, - parameters: { - docs: { - description: { - story: 'Segment objects allow specifying individual labels and colors for each segment.', - }, - }, - }, -}; - -export const CustomLabelFormatter: Story = { - args: { - values: [ 25, 50, 25 ], - width: 400, - height: 8, - labelFormatter: value => `${ value }%`, - }, - parameters: { - docs: { - description: { - story: 'A custom label formatter can be used to format the cumulative value labels.', - }, - }, - }, -}; - -export const WithGap: Story = { - args: { - values: [ 20, 30, 25, 25 ], - width: 400, - height: 10, - gap: 4, - borderRadius: 4, - }, - parameters: { - docs: { - description: { - story: 'Gaps can be added between segments for visual separation.', - }, - }, - }, -}; - -export const DashboardIntegration: Story = { - render: () => ( -
-
-
- Monthly OpEx - $180k -
- -
-
-
- Budget Utilization - 72% -
- -
-
- ), - parameters: { - docs: { - description: { - story: 'Example of CategoryBar integrated into a dashboard card layout.', - }, - }, - }, -}; - -export const ComparisonStack: Story = { - render: () => ( -
- { [ 'Jan', 'Feb', 'Mar', 'Apr' ].map( ( month, index ) => ( -
-
{ month }
- -
- ) ) } -
- ), - parameters: { - docs: { - description: { - story: 'Multiple CategoryBars stacked vertically to show comparison over time.', - }, - }, - }, -}; - -export const Responsive: Story = { - args: { - values: [ 30, 40, 30 ], - height: 10, - showLabels: true, - }, - parameters: { - docs: { - description: { - story: - 'The responsive variant adapts to container width. Resize the container to see the chart adapt.', - }, - }, - }, -}; - -export const EdgeCases: Story = { - render: () => ( -
-
-

Empty Data

- -
-
-

Single Segment

- -
-
-

Many Segments

- -
-
-

Very Small Values

- -
-
- ), - parameters: { - docs: { - description: { - story: 'Examples of how the CategoryBar handles edge cases and unusual data.', - }, - }, - }, }; From 9fa876799892c65e243a7874ce6cfc78dcc61f3c Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 16:01:21 +1100 Subject: [PATCH 14/17] Update CategoryBar docs to match simplified stories --- .../category-bar/stories/index.docs.mdx | 251 +++--------------- 1 file changed, 38 insertions(+), 213 deletions(-) diff --git a/projects/js-packages/charts/src/components/category-bar/stories/index.docs.mdx b/projects/js-packages/charts/src/components/category-bar/stories/index.docs.mdx index c24a9c6de7e69..b4b0701a9059b 100644 --- a/projects/js-packages/charts/src/components/category-bar/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/components/category-bar/stories/index.docs.mdx @@ -1,160 +1,50 @@ -import { Meta, Canvas, Story, Source } from '@storybook/addon-docs/blocks'; +import { Meta, Canvas, Source } from '@storybook/addon-docs/blocks'; import * as CategoryBarStories from './index.stories'; # Category Bar -The Category Bar is a horizontal segmented bar component for visualizing proportions, progress, performance, or status. It displays colored segments that can show proportional data or equal-width status indicators. +The Category Bar is a horizontal segmented bar component for visualizing proportions, progress, or status indicators. ## Overview -The Category Bar component provides a flexible way to display proportional data in a horizontal format. It supports two display modes, optional markers, cumulative labels, and full theme integration. +The Category Bar displays colored segments that can show proportional data or equal-width status indicators. Use the Storybook controls to explore different configurations. `} /> ## API Reference -For detailed information about component props, types, and exports, see the [Category Bar API Reference](./?path=/docs/js-packages-charts-types-category-bar-api-reference--docs). - -## Basic Usage - -### Simple Category Bar - -The simplest category bar requires only a `values` prop with an array of numbers: - - - -`} -/> - -### Required Props - -- **`values`**: Array of numbers or segment objects containing value, label, and optional color - -### Optional Props - -- **`mode`** (default: `'proportional'`): Display mode - `'proportional'` or `'equal'` -- **`width`** (default: `300`): Width of the bar in pixels -- **`height`** (default: `8`): Height of the bar in pixels -- **`colors`**: Custom colors for segments -- **`showLabels`** (default: `true`): Whether to show cumulative value labels -- **`marker`**: Optional position indicator -- **`gap`** (default: `0`): Gap between segments in pixels -- **`borderRadius`** (default: `4`): Corner radius for the bar -- **`withTooltips`** (default: `false`): Enables interactive tooltips on hover +For detailed prop information, see the [Category Bar API Reference](./?path=/docs/js-packages-charts-types-category-bar-api-reference--docs). ## Display Modes ### Proportional Mode (Default) -In proportional mode, segment widths are calculated based on their values relative to the total: - - - -`} -/> +Segment widths are based on their values relative to the total. ### Equal Mode -In equal mode, all segments have the same width regardless of their values. This is useful for status indicators: +All segments have the same width regardless of values. Useful for status indicators: -`} -/> - -## Marker Indicator - -Add a marker to indicate a specific position on the bar: - - - -`} -/> - -## Color Customization - -### Using Colors Prop - -Apply custom colors to segments: - - - -`} -/> - -### Using Segment Objects - -For more control, use segment objects with individual colors: - - - -`} -/> - -## Common Use Cases - -### Health Check Indicator - - - `} -/> - -### Progress Indicator - - - -`} /> -## Dashboard Integration - -The Category Bar works well in dashboard layouts: - - - -## Labels and Formatting - -### Custom Label Formatter +## Marker Indicator -Use a custom formatter for cumulative labels: +Add a marker to indicate a specific position on the bar: - + \`\${value}%\`} - width={400} + values={[25, 35, 25, 15]} + marker={{ + value: 60, + tooltip: 'Target: 60%', + showAnimation: true, + }} + withTooltips />`} /> -### Hiding Labels - -Labels can be hidden for a cleaner appearance: - - - -## Visual Customization - -### Adding Gaps Between Segments - - - -`} -/> +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `values` | `number[] \| SegmentValue[]` | required | Segment values (numbers or objects with value, label, color) | +| `mode` | `'proportional' \| 'equal'` | `'proportional'` | How segment widths are calculated | +| `colors` | `string[]` | theme colors | Custom colors for segments | +| `height` | `number` | `8` | Bar height in pixels | +| `gap` | `number` | `0` | Gap between segments | +| `borderRadius` | `number` | `4` | Corner radius | +| `showLabels` | `boolean` | `true` | Show cumulative percentage labels | +| `withTooltips` | `boolean` | `false` | Enable tooltips on hover | +| `marker` | `MarkerConfig` | - | Position indicator with optional tooltip | +| `labelFormatter` | `(value: number) => string` | - | Custom label format function | ## Theme Integration -The Category Bar automatically uses colors from the theme provided by `GlobalChartsProvider`: - - - -`} -/> - -Custom colors via the `colors` prop or segment objects will override theme colors. - -## Edge Cases - -The Category Bar handles various edge cases gracefully: - - - -- **Empty data**: Renders an empty placeholder -- **Single segment**: Displays a full-width bar -- **Many segments**: Handles numerous segments proportionally -- **Very small values**: Maintains visibility for tiny segments - -## Accessibility - -The Category Bar component: - -- Uses semantic HTML structure -- Supports tooltip descriptions for segment details -- Provides data-testid attributes for testing -- Maintains sufficient color contrast - -## Browser Compatibility - -The Category Bar uses standard CSS and HTML and is compatible with all modern browsers. +The Category Bar uses colors from `GlobalChartsProvider`. Custom colors via the `colors` prop override theme colors. From 642c833f9ceaba0eb9cdb3a263d28f87e2870092 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 16:30:13 +1100 Subject: [PATCH 15/17] Remove stacked bar chart changes from this PR --- .../src/components/bar-chart/bar-chart.tsx | 71 ++++++------------- .../bar-chart/stories/index.api.mdx | 1 - .../bar-chart/stories/index.stories.tsx | 42 ----------- 3 files changed, 21 insertions(+), 93 deletions(-) diff --git a/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx b/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx index f93631bc2377c..b30f311b8d9b4 100644 --- a/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx +++ b/projects/js-packages/charts/src/components/bar-chart/bar-chart.tsx @@ -1,6 +1,6 @@ import { formatNumber } from '@automattic/number-formatters'; import { PatternLines, PatternCircles, PatternWaves, PatternHexagons } from '@visx/pattern'; -import { Axis, BarSeries, BarGroup, BarStack, Grid, XYChart } from '@visx/xychart'; +import { Axis, BarSeries, BarGroup, Grid, XYChart } from '@visx/xychart'; import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; import { useCallback, useContext, useState, useRef, useMemo } from 'react'; @@ -39,11 +39,6 @@ export interface BarChartProps extends BaseChartProps< SeriesData[] > { showZeroValues?: boolean; legendInteractive?: boolean; children?: ReactNode; - /** - * When true, bars are stacked on top of each other instead of grouped side by side. - * Works with both horizontal and vertical orientations. - */ - stacked?: boolean; } // Base props type with optional responsive properties @@ -104,7 +99,6 @@ const BarChartInternal: FC< BarChartProps > = ( { legendInteractive = false, animation, children, - stacked = false, } ) => { const horizontal = orientation === 'horizontal'; const chartId = useChartId( providedChartId ); @@ -296,9 +290,8 @@ const BarChartInternal: FC< BarChartProps > = ( { () => ( { orientation, withPatterns, - stacked, } ), - [ orientation, withPatterns, stacked ] + [ orientation, withPatterns ] ); // Register chart with context only if data is valid @@ -407,47 +400,25 @@ const BarChartInternal: FC< BarChartProps > = ( { ) : null } - { stacked ? ( - - { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => { - // Skip rendering invisible series - if ( ! isVisible ) { - return null; - } - - return ( - - ); - } ) } - - ) : ( - - { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => { - // Skip rendering invisible series - if ( ! isVisible ) { - return null; - } - - return ( - - ); - } ) } - - ) } + + { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => { + // Skip rendering invisible series + if ( ! isVisible ) { + return null; + } + + return ( + + ); + } ) } + diff --git a/projects/js-packages/charts/src/components/bar-chart/stories/index.api.mdx b/projects/js-packages/charts/src/components/bar-chart/stories/index.api.mdx index 723f6a7afca5b..cc73a1c20df17 100644 --- a/projects/js-packages/charts/src/components/bar-chart/stories/index.api.mdx +++ b/projects/js-packages/charts/src/components/bar-chart/stories/index.api.mdx @@ -17,7 +17,6 @@ Main chart component with responsive behavior by default. | `width` | `number` | responsive | Chart width in pixels | | `height` | `number` | `400` | Chart height in pixels | | `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Bar orientation | -| `stacked` | `boolean` | `false` | Stack bars on top of each other instead of grouped side by side | | `withTooltips` | `boolean` | `false` | Enable interactive tooltips | | `withPatterns` | `boolean` | `false` | Use pattern fills for accessibility | | `showZeroValues` | `boolean` | `false` | Display zero values with minimum visual height | diff --git a/projects/js-packages/charts/src/components/bar-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/components/bar-chart/stories/index.stories.tsx index 243e93afdae9b..d16650e9c3079 100644 --- a/projects/js-packages/charts/src/components/bar-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/components/bar-chart/stories/index.stories.tsx @@ -55,11 +55,6 @@ const meta: Meta< StoryArgs > = { description: 'Use patterns for bars', table: { category: 'Visual Style' }, }, - stacked: { - control: 'boolean', - description: 'Stack bars on top of each other instead of grouping side by side', - table: { category: 'Visual Style' }, - }, }, render: args => { const { seriesCount, ...chartProps } = args; @@ -345,43 +340,6 @@ export const HorizontalBarChart: Story = { }, }; -// Stacked bar chart stories -export const StackedVertical: Story = { - args: { - ...Default.args, - data: [ medalCountsData[ 0 ], medalCountsData[ 1 ], medalCountsData[ 2 ] ], - stacked: true, - showLegend: true, - }, - parameters: { - docs: { - description: { - story: - 'Vertical stacked bar chart where bars are stacked on top of each other instead of grouped side by side. Useful for showing part-to-whole relationships across categories.', - }, - }, - }, -}; - -export const StackedHorizontal: Story = { - args: { - ...Default.args, - data: [ medalCountsData[ 0 ], medalCountsData[ 1 ], medalCountsData[ 2 ] ], - stacked: true, - orientation: 'horizontal', - showLegend: true, - gridVisibility: 'y', - }, - parameters: { - docs: { - description: { - story: - 'Horizontal stacked bar chart. Combines the stacked layout with horizontal orientation for a different visual presentation of the same data.', - }, - }, - }, -}; - const dataWithZeroValues = [ { group: 'United States', From fb040c89552d9f2954d05d9f1b14a55e0a9f48a5 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 16:47:28 +1100 Subject: [PATCH 16/17] Rename CategoryBar to SegmentedBar --- projects/js-packages/charts/package.json | 16 ++-- .../src/components/category-bar/index.ts | 9 -- .../src/components/segmented-bar/index.ts | 9 ++ .../segmented-bar.module.scss} | 2 +- .../segmented-bar.tsx} | 92 +++++++++---------- .../stories/index.api.mdx | 0 .../stories/index.docs.mdx | 28 +++--- .../stories/index.stories.tsx | 16 ++-- .../test/segmented-bar.test.tsx} | 80 ++++++++-------- .../{category-bar => segmented-bar}/types.ts | 16 ++-- projects/js-packages/charts/src/index.ts | 2 +- 11 files changed, 135 insertions(+), 135 deletions(-) delete mode 100644 projects/js-packages/charts/src/components/category-bar/index.ts create mode 100644 projects/js-packages/charts/src/components/segmented-bar/index.ts rename projects/js-packages/charts/src/components/{category-bar/category-bar.module.scss => segmented-bar/segmented-bar.module.scss} (98%) rename projects/js-packages/charts/src/components/{category-bar/category-bar.tsx => segmented-bar/segmented-bar.tsx} (74%) rename projects/js-packages/charts/src/components/{category-bar => segmented-bar}/stories/index.api.mdx (100%) rename projects/js-packages/charts/src/components/{category-bar => segmented-bar}/stories/index.docs.mdx (65%) rename projects/js-packages/charts/src/components/{category-bar => segmented-bar}/stories/index.stories.tsx (86%) rename projects/js-packages/charts/src/components/{category-bar/test/category-bar.test.tsx => segmented-bar/test/segmented-bar.test.tsx} (67%) rename projects/js-packages/charts/src/components/{category-bar => segmented-bar}/types.ts (87%) diff --git a/projects/js-packages/charts/package.json b/projects/js-packages/charts/package.json index c4d3e02753063..45a869879d99c 100644 --- a/projects/js-packages/charts/package.json +++ b/projects/js-packages/charts/package.json @@ -36,12 +36,6 @@ "require": "./dist/components/bar-list-chart/index.cjs" }, "./bar-list-chart/style.css": "./dist/components/bar-list-chart/index.css", - "./category-bar": { - "jetpack:src": "./src/components/category-bar/index.ts", - "import": "./dist/components/category-bar/index.js", - "require": "./dist/components/category-bar/index.cjs" - }, - "./category-bar/style.css": "./dist/components/category-bar/index.css", "./conversion-funnel-chart": { "jetpack:src": "./src/components/conversion-funnel-chart/index.ts", "import": "./dist/components/conversion-funnel-chart/index.js", @@ -88,6 +82,12 @@ "import": "./dist/providers/index.js", "require": "./dist/providers/index.cjs" }, + "./segmented-bar": { + "jetpack:src": "./src/components/segmented-bar/index.ts", + "import": "./dist/components/segmented-bar/index.js", + "require": "./dist/components/segmented-bar/index.cjs" + }, + "./segmented-bar/style.css": "./dist/components/segmented-bar/index.css", "./style.css": "./dist/index.css", "./tooltip": { "jetpack:src": "./src/components/tooltip/index.ts", @@ -138,8 +138,8 @@ "bar-list-chart": [ "./dist/components/bar-list-chart/index.d.ts" ], - "category-bar": [ - "./dist/components/category-bar/index.d.ts" + "segmented-bar": [ + "./dist/components/segmented-bar/index.d.ts" ], "leaderboard-chart": [ "./dist/components/leaderboard-chart/index.d.ts" diff --git a/projects/js-packages/charts/src/components/category-bar/index.ts b/projects/js-packages/charts/src/components/category-bar/index.ts deleted file mode 100644 index 6ae3d013c5dc0..0000000000000 --- a/projects/js-packages/charts/src/components/category-bar/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { default as CategoryBar, CategoryBarUnresponsive } from './category-bar'; -export type { CategoryBarResponsiveConfig } from './category-bar'; - -export type { - CategoryBarProps, - CategoryBarSegment, - CategoryBarMarker, - CategoryBarMode, -} from './types'; diff --git a/projects/js-packages/charts/src/components/segmented-bar/index.ts b/projects/js-packages/charts/src/components/segmented-bar/index.ts new file mode 100644 index 0000000000000..537a002ebb1ec --- /dev/null +++ b/projects/js-packages/charts/src/components/segmented-bar/index.ts @@ -0,0 +1,9 @@ +export { default as SegmentedBar, SegmentedBarUnresponsive } from './segmented-bar'; +export type { SegmentedBarResponsiveConfig } from './segmented-bar'; + +export type { + SegmentedBarProps, + SegmentedBarSegment, + SegmentedBarMarker, + SegmentedBarMode, +} from './types'; diff --git a/projects/js-packages/charts/src/components/category-bar/category-bar.module.scss b/projects/js-packages/charts/src/components/segmented-bar/segmented-bar.module.scss similarity index 98% rename from projects/js-packages/charts/src/components/category-bar/category-bar.module.scss rename to projects/js-packages/charts/src/components/segmented-bar/segmented-bar.module.scss index 75df43a86f981..41210b6514bc4 100644 --- a/projects/js-packages/charts/src/components/category-bar/category-bar.module.scss +++ b/projects/js-packages/charts/src/components/segmented-bar/segmented-bar.module.scss @@ -1,4 +1,4 @@ -.categoryBar { +.segmentedBar { display: flex; flex-direction: column; position: relative; diff --git a/projects/js-packages/charts/src/components/category-bar/category-bar.tsx b/projects/js-packages/charts/src/components/segmented-bar/segmented-bar.tsx similarity index 74% rename from projects/js-packages/charts/src/components/category-bar/category-bar.tsx rename to projects/js-packages/charts/src/components/segmented-bar/segmented-bar.tsx index 47ef7f805dfde..3ca4c8d261dd8 100644 --- a/projects/js-packages/charts/src/components/category-bar/category-bar.tsx +++ b/projects/js-packages/charts/src/components/segmented-bar/segmented-bar.tsx @@ -8,8 +8,8 @@ import { useGlobalChartsTheme, useChartId, } from '../../providers'; -import styles from './category-bar.module.scss'; -import type { CategoryBarProps, CategoryBarSegment } from './types'; +import styles from './segmented-bar.module.scss'; +import type { SegmentedBarProps, SegmentedBarSegment } from './types'; import type { MouseEvent, FC } from 'react'; const DEFAULT_WIDTH = 300; @@ -19,19 +19,19 @@ const DEFAULT_BORDER_RADIUS = 4; const DEFAULT_LABEL_FORMATTER = ( value: number ) => value.toString(); /** - * Normalizes input values to CategoryBarSegment array. + * Normalizes input values to SegmentedBarSegment array. * @param values - Input values array (numbers or segment objects). * @param colors - Optional custom colors array. * @param themeColors - Theme colors to use as fallback. - * @return Normalized array of CategoryBarSegment objects. + * @return Normalized array of SegmentedBarSegment objects. */ const normalizeSegments = ( - values: number[] | CategoryBarSegment[], + values: number[] | SegmentedBarSegment[], colors: string[] | undefined, themeColors: string[] -): CategoryBarSegment[] => { +): SegmentedBarSegment[] => { return ( values || [] ).map( ( value, index ) => { - const segment: CategoryBarSegment = typeof value === 'number' ? { value } : { ...value }; + const segment: SegmentedBarSegment = typeof value === 'number' ? { value } : { ...value }; // Apply color priority: segment.color > colors prop > theme colors if ( ! segment.color ) { @@ -42,7 +42,7 @@ const normalizeSegments = ( } ); }; -const CategoryBarComponent = forwardRef< HTMLDivElement, CategoryBarProps >( +const SegmentedBarComponent = forwardRef< HTMLDivElement, SegmentedBarProps >( ( { values, @@ -127,7 +127,7 @@ const CategoryBarComponent = forwardRef< HTMLDivElement, CategoryBarProps >( } ); const createSegmentMouseMoveHandler = useCallback( - ( segment: CategoryBarSegment ) => ( event: MouseEvent< HTMLDivElement > ) => { + ( segment: SegmentedBarSegment ) => ( event: MouseEvent< HTMLDivElement > ) => { if ( ! withTooltips ) { return; } @@ -161,9 +161,9 @@ const CategoryBarComponent = forwardRef< HTMLDivElement, CategoryBarProps >( return (
); } @@ -175,13 +175,13 @@ const CategoryBarComponent = forwardRef< HTMLDivElement, CategoryBarProps >( return (
( return (
( } } onMouseMove={ segmentMouseMoveHandlers[ index ] } onMouseLeave={ handleMouseLeave } - data-testid={ `category-bar-segment-${ index }` } + data-testid={ `segmented-bar-segment-${ index }` } /> ); } ) } { marker && markerPosition !== null && (
( top: -4, backgroundColor: marker.color || theme?.gridColor || '#374151', } } - data-testid="category-bar-marker" + data-testid="segmented-bar-marker" title={ marker.tooltip } /> ) }
{ showLabels && ( -
+
{ labelPositions.map( ( pos, index ) => ( { labelFormatter( pos.value ) } @@ -251,11 +251,11 @@ const CategoryBarComponent = forwardRef< HTMLDivElement, CategoryBarProps >( { withTooltips && tooltipOpen && tooltipData && ( -
+
{ tooltipData.label && ( - { tooltipData.label }: + { tooltipData.label }: ) } - { tooltipData.value } + { tooltipData.value }
) } @@ -264,38 +264,38 @@ const CategoryBarComponent = forwardRef< HTMLDivElement, CategoryBarProps >( } ); -CategoryBarComponent.displayName = 'CategoryBarComponent'; +SegmentedBarComponent.displayName = 'SegmentedBarComponent'; /** - * CategoryBar chart component with GlobalChartsProvider wrapper. - * @param props - CategoryBar component props. - * @return CategoryBar component wrapped in provider if needed. + * SegmentedBar chart component with GlobalChartsProvider wrapper. + * @param props - SegmentedBar component props. + * @return SegmentedBar component wrapped in provider if needed. */ -const CategoryBarWithProvider: FC< CategoryBarProps > = props => { +const SegmentedBarWithProvider: FC< SegmentedBarProps > = props => { const existingContext = useContext( GlobalChartsContext ); // If we're already in a GlobalChartsProvider context, don't create a new one if ( existingContext ) { - return ; + return ; } // Otherwise, create our own GlobalChartsProvider return ( - + ); }; -CategoryBarWithProvider.displayName = 'CategoryBarUnresponsive'; +SegmentedBarWithProvider.displayName = 'SegmentedBarUnresponsive'; // Export the provider-wrapped component as the unresponsive variant -const CategoryBarUnresponsive = CategoryBarWithProvider; +const SegmentedBarUnresponsive = SegmentedBarWithProvider; /** - * Responsive configuration for CategoryBar + * Responsive configuration for SegmentedBar */ -export type CategoryBarResponsiveConfig = { +export type SegmentedBarResponsiveConfig = { /** * The maximum width of the chart. Defaults to 1200. */ @@ -307,17 +307,17 @@ export type CategoryBarResponsiveConfig = { }; /** - * Responsive CategoryBar chart component. + * Responsive SegmentedBar chart component. * @param props - Component props including responsive configuration. * @param props.resizeDebounceTime - Debounce time for resize events. * @param props.maxWidth - Maximum width constraint. - * @return Responsive CategoryBar component. + * @return Responsive SegmentedBar component. */ -const CategoryBar = ( { +const SegmentedBar = ( { resizeDebounceTime = 300, maxWidth = 1200, ...chartProps -}: Omit< CategoryBarProps, 'width' > & CategoryBarResponsiveConfig & { width?: number } ) => { +}: Omit< SegmentedBarProps, 'width' > & SegmentedBarResponsiveConfig & { width?: number } ) => { const { parentRef, width: parentWidth } = useParentSize( { debounceTime: resizeDebounceTime, enableDebounceLeadingCall: true, @@ -332,7 +332,7 @@ const CategoryBar = ( { width: chartProps.width ?? '100%', } } > - @@ -340,6 +340,6 @@ const CategoryBar = ( { ); }; -CategoryBar.displayName = 'CategoryBar'; +SegmentedBar.displayName = 'SegmentedBar'; -export { CategoryBar as default, CategoryBarUnresponsive }; +export { SegmentedBar as default, SegmentedBarUnresponsive }; diff --git a/projects/js-packages/charts/src/components/category-bar/stories/index.api.mdx b/projects/js-packages/charts/src/components/segmented-bar/stories/index.api.mdx similarity index 100% rename from projects/js-packages/charts/src/components/category-bar/stories/index.api.mdx rename to projects/js-packages/charts/src/components/segmented-bar/stories/index.api.mdx diff --git a/projects/js-packages/charts/src/components/category-bar/stories/index.docs.mdx b/projects/js-packages/charts/src/components/segmented-bar/stories/index.docs.mdx similarity index 65% rename from projects/js-packages/charts/src/components/category-bar/stories/index.docs.mdx rename to projects/js-packages/charts/src/components/segmented-bar/stories/index.docs.mdx index b4b0701a9059b..bc53767d8d3a0 100644 --- a/projects/js-packages/charts/src/components/category-bar/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/components/segmented-bar/stories/index.docs.mdx @@ -1,23 +1,23 @@ import { Meta, Canvas, Source } from '@storybook/addon-docs/blocks'; -import * as CategoryBarStories from './index.stories'; +import * as SegmentedBarStories from './index.stories'; - + -# Category Bar +# Segmented Bar -The Category Bar is a horizontal segmented bar component for visualizing proportions, progress, or status indicators. +The Segmented Bar is a horizontal segmented bar component for visualizing proportions, progress, or status indicators. - + ## Overview -The Category Bar displays colored segments that can show proportional data or equal-width status indicators. Use the Storybook controls to explore different configurations. +The Segmented Bar displays colored segments that can show proportional data or equal-width status indicators. Use the Storybook controls to explore different configurations. + + = { - title: 'JS Packages/Charts/Types/Category Bar', - component: CategoryBar, +const meta: Meta< SegmentedBarProps > = { + title: 'JS Packages/Charts/Types/Segmented Bar', + component: SegmentedBar, parameters: { layout: 'padded', }, @@ -54,10 +54,10 @@ const meta: Meta< CategoryBarProps > = { }; export default meta; -type Story = StoryObj< typeof CategoryBar >; +type Story = StoryObj< typeof SegmentedBar >; /** - * Default category bar with proportional segments. + * Default segmented bar with proportional segments. * Use the controls to explore different configurations. */ export const Default: Story = { @@ -94,7 +94,7 @@ export const EqualMode: Story = { }; /** - * Category bar with a marker indicating a specific position. + * Segmented bar with a marker indicating a specific position. */ export const WithMarker: Story = { args: { diff --git a/projects/js-packages/charts/src/components/category-bar/test/category-bar.test.tsx b/projects/js-packages/charts/src/components/segmented-bar/test/segmented-bar.test.tsx similarity index 67% rename from projects/js-packages/charts/src/components/category-bar/test/category-bar.test.tsx rename to projects/js-packages/charts/src/components/segmented-bar/test/segmented-bar.test.tsx index 2182b0b194da1..2f43d9d63d23e 100644 --- a/projects/js-packages/charts/src/components/category-bar/test/category-bar.test.tsx +++ b/projects/js-packages/charts/src/components/segmented-bar/test/segmented-bar.test.tsx @@ -3,17 +3,17 @@ */ import { render, screen } from '@testing-library/react'; -import { CategoryBar, CategoryBarUnresponsive } from '../'; +import { SegmentedBar, SegmentedBarUnresponsive } from '../'; import { GlobalChartsProvider, jetpackTheme, wooTheme } from '../../../providers'; -describe( 'CategoryBar', () => { +describe( 'SegmentedBar', () => { const defaultData = [ 25, 50, 25 ]; const renderWithTheme = ( props = {}, themeName = 'jetpack' ) => { const theme = themeName === 'jetpack' ? jetpackTheme : wooTheme; return render( - + ); }; @@ -21,14 +21,14 @@ describe( 'CategoryBar', () => { describe( 'Basic Rendering', () => { test( 'renders with array of numbers', () => { renderWithTheme(); - expect( screen.getByTestId( 'category-bar' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'segmented-bar' ) ).toBeInTheDocument(); } ); test( 'renders correct number of segments', () => { renderWithTheme(); - expect( screen.getByTestId( 'category-bar-segment-0' ) ).toBeInTheDocument(); - expect( screen.getByTestId( 'category-bar-segment-1' ) ).toBeInTheDocument(); - expect( screen.getByTestId( 'category-bar-segment-2' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'segmented-bar-segment-0' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'segmented-bar-segment-1' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'segmented-bar-segment-2' ) ).toBeInTheDocument(); } ); test( 'renders with segment objects', () => { @@ -38,30 +38,30 @@ describe( 'CategoryBar', () => { { value: 70, label: 'Second' }, ], } ); - expect( screen.getByTestId( 'category-bar-segment-0' ) ).toBeInTheDocument(); - expect( screen.getByTestId( 'category-bar-segment-1' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'segmented-bar-segment-0' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'segmented-bar-segment-1' ) ).toBeInTheDocument(); } ); test( 'applies custom className', () => { renderWithTheme( { className: 'custom-class' } ); - expect( screen.getByTestId( 'category-bar' ) ).toHaveClass( 'custom-class' ); + expect( screen.getByTestId( 'segmented-bar' ) ).toHaveClass( 'custom-class' ); } ); test( 'renders responsive variant', () => { render( - + ); - expect( screen.getByTestId( 'category-bar' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'segmented-bar' ) ).toBeInTheDocument(); } ); } ); describe( 'Display Modes', () => { test( 'proportional mode: segment widths reflect values', () => { renderWithTheme( { values: [ 25, 75 ], width: 200, gap: 0 } ); - const segment0 = screen.getByTestId( 'category-bar-segment-0' ); - const segment1 = screen.getByTestId( 'category-bar-segment-1' ); + const segment0 = screen.getByTestId( 'segmented-bar-segment-0' ); + const segment1 = screen.getByTestId( 'segmented-bar-segment-1' ); // First segment should be 25% width, second 75% expect( segment0 ).toHaveStyle( { width: '50px' } ); // 25% of 200 @@ -70,8 +70,8 @@ describe( 'CategoryBar', () => { test( 'equal mode: all segments same width', () => { renderWithTheme( { values: [ 25, 75 ], mode: 'equal', width: 200, gap: 0 } ); - const segment0 = screen.getByTestId( 'category-bar-segment-0' ); - const segment1 = screen.getByTestId( 'category-bar-segment-1' ); + const segment0 = screen.getByTestId( 'segmented-bar-segment-0' ); + const segment1 = screen.getByTestId( 'segmented-bar-segment-1' ); // Both segments should be 50% width expect( segment0 ).toHaveStyle( { width: '100px' } ); @@ -82,19 +82,19 @@ describe( 'CategoryBar', () => { describe( 'Edge Cases', () => { test( 'handles empty values array', () => { renderWithTheme( { values: [] } ); - expect( screen.getByTestId( 'category-bar-empty' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'segmented-bar-empty' ) ).toBeInTheDocument(); } ); test( 'handles single segment', () => { renderWithTheme( { values: [ 100 ] } ); - expect( screen.getByTestId( 'category-bar-segment-0' ) ).toBeInTheDocument(); - expect( screen.queryByTestId( 'category-bar-segment-1' ) ).not.toBeInTheDocument(); + expect( screen.getByTestId( 'segmented-bar-segment-0' ) ).toBeInTheDocument(); + expect( screen.queryByTestId( 'segmented-bar-segment-1' ) ).not.toBeInTheDocument(); } ); test( 'handles all zero values', () => { renderWithTheme( { values: [ 0, 0, 0 ], mode: 'equal', width: 300, gap: 0 } ); // Should render equal segments when all values are zero - const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + const segment0 = screen.getByTestId( 'segmented-bar-segment-0' ); // Allow for floating point precision issues const computedWidth = parseFloat( segment0.style.width ); expect( computedWidth ).toBeCloseTo( 100, 0 ); @@ -107,8 +107,8 @@ describe( 'CategoryBar', () => { values: [ 50, 50 ], colors: [ '#ff0000', '#00ff00' ], } ); - const segment0 = screen.getByTestId( 'category-bar-segment-0' ); - const segment1 = screen.getByTestId( 'category-bar-segment-1' ); + const segment0 = screen.getByTestId( 'segmented-bar-segment-0' ); + const segment1 = screen.getByTestId( 'segmented-bar-segment-1' ); expect( segment0 ).toHaveStyle( { backgroundColor: '#ff0000' } ); expect( segment1 ).toHaveStyle( { backgroundColor: '#00ff00' } ); @@ -119,8 +119,8 @@ describe( 'CategoryBar', () => { values: [ { value: 50, color: '#0000ff' }, { value: 50 } ], colors: [ '#ff0000', '#00ff00' ], } ); - const segment0 = screen.getByTestId( 'category-bar-segment-0' ); - const segment1 = screen.getByTestId( 'category-bar-segment-1' ); + const segment0 = screen.getByTestId( 'segmented-bar-segment-0' ); + const segment1 = screen.getByTestId( 'segmented-bar-segment-1' ); expect( segment0 ).toHaveStyle( { backgroundColor: '#0000ff' } ); expect( segment1 ).toHaveStyle( { backgroundColor: '#00ff00' } ); @@ -128,8 +128,8 @@ describe( 'CategoryBar', () => { test( 'border radius applied to first and last segments', () => { renderWithTheme( { values: [ 33, 34, 33 ], borderRadius: 8 } ); - const segment0 = screen.getByTestId( 'category-bar-segment-0' ); - const segment2 = screen.getByTestId( 'category-bar-segment-2' ); + const segment0 = screen.getByTestId( 'segmented-bar-segment-0' ); + const segment2 = screen.getByTestId( 'segmented-bar-segment-2' ); expect( segment0 ).toHaveStyle( { borderTopLeftRadius: '8px' } ); expect( segment0 ).toHaveStyle( { borderBottomLeftRadius: '8px' } ); @@ -141,13 +141,13 @@ describe( 'CategoryBar', () => { describe( 'Dimensions', () => { test( 'applies default dimensions', () => { renderWithTheme(); - const bar = screen.getByTestId( 'category-bar' ); + const bar = screen.getByTestId( 'segmented-bar' ); expect( bar ).toHaveStyle( { width: '300px' } ); } ); test( 'applies custom dimensions', () => { renderWithTheme( { width: 400, height: 12 } ); - const bar = screen.getByTestId( 'category-bar' ); + const bar = screen.getByTestId( 'segmented-bar' ); expect( bar ).toHaveStyle( { width: '400px' } ); } ); } ); @@ -156,13 +156,13 @@ describe( 'CategoryBar', () => { test( 'shows labels when showLabels=true', () => { renderWithTheme( { showLabels: true } ); // Should show cumulative labels: 0, 25, 75, 100 - expect( screen.getByTestId( 'category-bar-label-0' ) ).toHaveTextContent( '0' ); - expect( screen.getByTestId( 'category-bar-label-3' ) ).toHaveTextContent( '100' ); + expect( screen.getByTestId( 'segmented-bar-label-0' ) ).toHaveTextContent( '0' ); + expect( screen.getByTestId( 'segmented-bar-label-3' ) ).toHaveTextContent( '100' ); } ); test( 'hides labels when showLabels=false', () => { renderWithTheme( { showLabels: false } ); - expect( screen.queryByTestId( 'category-bar-label-0' ) ).not.toBeInTheDocument(); + expect( screen.queryByTestId( 'segmented-bar-label-0' ) ).not.toBeInTheDocument(); } ); test( 'custom labelFormatter works', () => { @@ -170,8 +170,8 @@ describe( 'CategoryBar', () => { showLabels: true, labelFormatter: value => `${ value }%`, } ); - expect( screen.getByTestId( 'category-bar-label-0' ) ).toHaveTextContent( '0%' ); - expect( screen.getByTestId( 'category-bar-label-3' ) ).toHaveTextContent( '100%' ); + expect( screen.getByTestId( 'segmented-bar-label-0' ) ).toHaveTextContent( '0%' ); + expect( screen.getByTestId( 'segmented-bar-label-3' ) ).toHaveTextContent( '100%' ); } ); } ); @@ -181,7 +181,7 @@ describe( 'CategoryBar', () => { values: [ 50, 50 ], marker: { value: 25 }, } ); - const marker = screen.getByTestId( 'category-bar-marker' ); + const marker = screen.getByTestId( 'segmented-bar-marker' ); expect( marker ).toBeInTheDocument(); expect( marker ).toHaveStyle( { left: '25%' } ); } ); @@ -191,7 +191,7 @@ describe( 'CategoryBar', () => { values: [ 50, 50 ], marker: { value: 50, tooltip: 'Halfway point' }, } ); - const marker = screen.getByTestId( 'category-bar-marker' ); + const marker = screen.getByTestId( 'segmented-bar-marker' ); expect( marker ).toHaveAttribute( 'title', 'Halfway point' ); } ); @@ -200,7 +200,7 @@ describe( 'CategoryBar', () => { values: [ 50, 50 ], marker: { value: 50, showAnimation: true }, } ); - const marker = screen.getByTestId( 'category-bar-marker' ); + const marker = screen.getByTestId( 'segmented-bar-marker' ); // Marker should be present - animation is a CSS-only visual effect expect( marker ).toBeInTheDocument(); expect( marker ).toHaveStyle( { left: '50%' } ); @@ -210,14 +210,14 @@ describe( 'CategoryBar', () => { describe( 'Theme Integration', () => { test( 'uses jetpack theme colors', () => { renderWithTheme( { values: [ 50, 50 ] }, 'jetpack' ); - const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + const segment0 = screen.getByTestId( 'segmented-bar-segment-0' ); // Jetpack theme first color expect( segment0 ).toHaveStyle( { backgroundColor: jetpackTheme.colors[ 0 ] } ); } ); test( 'uses woo theme colors', () => { renderWithTheme( { values: [ 50, 50 ] }, 'woo' ); - const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + const segment0 = screen.getByTestId( 'segmented-bar-segment-0' ); // Woo theme first color expect( segment0 ).toHaveStyle( { backgroundColor: wooTheme.colors[ 0 ] } ); } ); @@ -227,7 +227,7 @@ describe( 'CategoryBar', () => { values: [ 50, 50 ], colors: [ '#custom1', '#custom2' ], } ); - const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + const segment0 = screen.getByTestId( 'segmented-bar-segment-0' ); expect( segment0 ).toHaveStyle( { backgroundColor: '#custom1' } ); } ); } ); @@ -237,7 +237,7 @@ describe( 'CategoryBar', () => { renderWithTheme( { values: [ 50, 50 ], gap: 4, width: 204 } ); // With 4px gap and 204px width, available width is 200px // Each segment should be 100px (50% of 200) - const segment0 = screen.getByTestId( 'category-bar-segment-0' ); + const segment0 = screen.getByTestId( 'segmented-bar-segment-0' ); expect( segment0 ).toHaveStyle( { width: '100px' } ); } ); } ); diff --git a/projects/js-packages/charts/src/components/category-bar/types.ts b/projects/js-packages/charts/src/components/segmented-bar/types.ts similarity index 87% rename from projects/js-packages/charts/src/components/category-bar/types.ts rename to projects/js-packages/charts/src/components/segmented-bar/types.ts index b31f2a623aa93..2ceb004558c2a 100644 --- a/projects/js-packages/charts/src/components/category-bar/types.ts +++ b/projects/js-packages/charts/src/components/segmented-bar/types.ts @@ -1,7 +1,7 @@ /** - * Single segment in the category bar + * Single segment in the segmented bar */ -export interface CategoryBarSegment { +export interface SegmentedBarSegment { /** * Numeric value for this segment. * In 'proportional' mode, determines segment width relative to total. @@ -24,7 +24,7 @@ export interface CategoryBarSegment { /** * Marker configuration for indicating a position on the bar */ -export interface CategoryBarMarker { +export interface SegmentedBarMarker { /** * Position value where the marker should appear. * Interpreted as cumulative value from left (0 to total). @@ -51,14 +51,14 @@ export interface CategoryBarMarker { /** * Display mode for segment sizing */ -export type CategoryBarMode = 'proportional' | 'equal'; +export type SegmentedBarMode = 'proportional' | 'equal'; -export interface CategoryBarProps { +export interface SegmentedBarProps { /** * Array of segments to display. * Can be simple numbers or full segment objects. */ - values: number[] | CategoryBarSegment[]; + values: number[] | SegmentedBarSegment[]; /** * Display mode for segments. @@ -66,7 +66,7 @@ export interface CategoryBarProps { * - 'equal': All segments have equal width * @default 'proportional' */ - mode?: CategoryBarMode; + mode?: SegmentedBarMode; /** * Custom colors for segments (overrides theme colors) @@ -77,7 +77,7 @@ export interface CategoryBarProps { /** * Optional marker to indicate a position on the bar */ - marker?: CategoryBarMarker; + marker?: SegmentedBarMarker; /** * Whether to show cumulative value labels below the bar diff --git a/projects/js-packages/charts/src/index.ts b/projects/js-packages/charts/src/index.ts index 16492889a2d0c..9a288b662c7c7 100644 --- a/projects/js-packages/charts/src/index.ts +++ b/projects/js-packages/charts/src/index.ts @@ -9,7 +9,7 @@ export { export { BarListChart, BarListChartUnresponsive } from './components/bar-list-chart'; export { LeaderboardChart, LeaderboardChartUnresponsive } from './components/leaderboard-chart'; export { ConversionFunnelChart } from './components/conversion-funnel-chart'; -export { CategoryBar, CategoryBarUnresponsive } from './components/category-bar'; +export { SegmentedBar, SegmentedBarUnresponsive } from './components/segmented-bar'; // Chart components export { BaseTooltip } from './components/tooltip'; From 136bfc13343adcf29c240c301f132b732d4309e7 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 4 Dec 2025 17:21:28 +1100 Subject: [PATCH 17/17] Fix remaining CategoryBar references and documentation --- .../changelog/add-new-chart-categorybar | 2 +- .../segmented-bar/stories/index.api.mdx | 100 +++++++++--------- .../src/components/segmented-bar/types.ts | 2 +- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/projects/js-packages/charts/changelog/add-new-chart-categorybar b/projects/js-packages/charts/changelog/add-new-chart-categorybar index c52b57b741af4..8e3387e97ecb6 100644 --- a/projects/js-packages/charts/changelog/add-new-chart-categorybar +++ b/projects/js-packages/charts/changelog/add-new-chart-categorybar @@ -1,4 +1,4 @@ Significance: minor Type: added -Charts: adds a category bar chart +Charts: Add SegmentedBar component for horizontal segmented progress bars diff --git a/projects/js-packages/charts/src/components/segmented-bar/stories/index.api.mdx b/projects/js-packages/charts/src/components/segmented-bar/stories/index.api.mdx index 0a4c0512e8dc8..f03022206d462 100644 --- a/projects/js-packages/charts/src/components/segmented-bar/stories/index.api.mdx +++ b/projects/js-packages/charts/src/components/segmented-bar/stories/index.api.mdx @@ -1,19 +1,19 @@ import { Meta, Source } from '@storybook/addon-docs/blocks'; - + -# Category Bar API Reference +# Segmented Bar API Reference -Complete API documentation for the Category Bar component. +Complete API documentation for the Segmented Bar component. ## Props | Prop | Type | Default | Description | |------|------|---------|-------------| -| `values` | `number[] \| CategoryBarSegment[]` | Required | Array of segment values or segment objects | +| `values` | `number[] \| SegmentedBarSegment[]` | Required | Array of segment values or segment objects | | `mode` | `'proportional' \| 'equal'` | `'proportional'` | How segment widths are calculated | | `colors` | `string[]` | Theme colors | Custom colors for segments | -| `marker` | `CategoryBarMarker` | - | Optional position indicator | +| `marker` | `SegmentedBarMarker` | - | Optional position indicator | | `showLabels` | `boolean` | `true` | Whether to show cumulative value labels | | `width` | `number` | `300` | Width of the bar in pixels | | `height` | `number` | `8` | Height of the bar in pixels | @@ -27,11 +27,11 @@ Complete API documentation for the Category Bar component. ## Type Definitions -### CategoryBarSegment +### SegmentedBarSegment -### CategoryBarMarker +### SegmentedBarMarker -### CategoryBarMode +### SegmentedBarMode -### CategoryBarProps +### SegmentedBarProps ## Component Variants -### CategoryBar (Responsive) +### SegmentedBar (Responsive) The default export that automatically adapts to container width: `} +`} /> -### CategoryBarUnresponsive +### SegmentedBarUnresponsive Fixed-size variant that requires explicit width: `} +`} /> ## CSS Classes @@ -159,19 +159,19 @@ The component uses CSS Modules with the following class structure: | Class | Description | |-------|-------------| -| `.categoryBar` | Root container element | -| `.categoryBar--empty` | Applied when values array is empty | -| `.categoryBar__bar` | The horizontal bar container | -| `.categoryBar__segment` | Individual segment elements | -| `.categoryBar__segment--first` | First segment (has left border radius) | -| `.categoryBar__segment--last` | Last segment (has right border radius) | -| `.categoryBar__marker` | Position marker element | -| `.categoryBar__marker--animated` | Marker with animation enabled | -| `.categoryBar__labels` | Labels container | -| `.categoryBar__label` | Individual label elements | -| `.categoryBar__tooltip` | Tooltip container | -| `.categoryBar__tooltipLabel` | Tooltip label text | -| `.categoryBar__tooltipValue` | Tooltip value text | +| `.segmentedBar` | Root container element | +| `.segmentedBar--empty` | Applied when values array is empty | +| `.segmentedBar__bar` | The horizontal bar container | +| `.segmentedBar__segment` | Individual segment elements | +| `.segmentedBar__segment--first` | First segment (has left border radius) | +| `.segmentedBar__segment--last` | Last segment (has right border radius) | +| `.segmentedBar__marker` | Position marker element | +| `.segmentedBar__marker--animated` | Marker with animation enabled | +| `.segmentedBar__labels` | Labels container | +| `.segmentedBar__label` | Individual label elements | +| `.segmentedBar__tooltip` | Tooltip container | +| `.segmentedBar__tooltipLabel` | Tooltip label text | +| `.segmentedBar__tooltipValue` | Tooltip value text | ## Display Modes @@ -216,11 +216,11 @@ The component uses `GlobalChartsProvider` for theming: - + `} /> @@ -236,7 +236,7 @@ Colors are applied in this priority order: | Scenario | Behavior | |----------|----------| -| Empty `values` array | Renders empty placeholder with `data-testid="category-bar-empty"` | +| Empty `values` array | Renders empty placeholder with `data-testid="segmented-bar-empty"` | | Single segment | Renders full-width bar | | All zero values | Equal mode: equal segments; Proportional: equal segments | | Marker value > total | Marker clamped to 100% | @@ -248,11 +248,11 @@ For testing, the component provides these test IDs: | Test ID | Description | |---------|-------------| -| `category-bar` | Main component container | -| `category-bar-empty` | Empty state container | -| `category-bar-segment-{index}` | Individual segments (0-indexed) | -| `category-bar-marker` | Position marker | -| `category-bar-label-{index}` | Cumulative labels (0-indexed) | +| `segmented-bar` | Main component container | +| `segmented-bar-empty` | Empty state container | +| `segmented-bar-segment-{index}` | Individual segments (0-indexed) | +| `segmented-bar-marker` | Position marker | +| `segmented-bar-label-{index}` | Cumulative labels (0-indexed) | ## Performance Notes diff --git a/projects/js-packages/charts/src/components/segmented-bar/types.ts b/projects/js-packages/charts/src/components/segmented-bar/types.ts index 2ceb004558c2a..e3118b24f522a 100644 --- a/projects/js-packages/charts/src/components/segmented-bar/types.ts +++ b/projects/js-packages/charts/src/components/segmented-bar/types.ts @@ -43,7 +43,7 @@ export interface SegmentedBarMarker { showAnimation?: boolean; /** - * Marker color (defaults to theme text color) + * Marker color (defaults to theme grid color) */ color?: string; }