diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 706733437a..0ab4cfef9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,8 @@ jobs: - name: Checkout uses: actions/checkout@v3 - name: Install pnpm - uses: pnpm/action-setup@v2 + uses: pnpm/action-setup@v4 with: - version: 8 run_install: false - name: Setup Node uses: actions/setup-node@v3 @@ -38,9 +37,8 @@ jobs: - name: Checkout uses: actions/checkout@v3 - name: Install pnpm - uses: pnpm/action-setup@v2 + uses: pnpm/action-setup@v4 with: - version: 8 run_install: false - name: Setup Node uses: actions/setup-node@v3 diff --git a/packages/bar/src/Bar.tsx b/packages/bar/src/Bar.tsx index 76e6670a05..797b559e4c 100644 --- a/packages/bar/src/Bar.tsx +++ b/packages/bar/src/Bar.tsx @@ -17,12 +17,14 @@ import { svgDefaultProps } from './props' import { BarCustomLayerProps, BarDatum, + BarItemProps, BarLayer, BarLayerId, BarSvgProps, ComputedBarDatumWithValue, } from './types' import { BarTotals } from './BarTotals' +import { useComputeLabelLayout } from './compute/common' type InnerBarProps = Omit< BarSvgProps, @@ -67,6 +69,8 @@ const InnerBar = ({ labelSkipWidth = svgDefaultProps.labelSkipWidth, labelSkipHeight = svgDefaultProps.labelSkipHeight, labelTextColor, + labelPosition = svgDefaultProps.labelPosition, + labelOffset = svgDefaultProps.labelOffset, markers = svgDefaultProps.markers, @@ -101,6 +105,8 @@ const InnerBar = ({ barAriaLabel, barAriaLabelledBy, barAriaDescribedBy, + barAriaHidden, + barAriaDisabled, initialHiddenIds, @@ -159,6 +165,8 @@ const InnerBar = ({ totalsOffset, }) + const computeLabelLayout = useComputeLabelLayout(layout, reverse, labelPosition, labelOffset) + const transition = useTransition< ComputedBarDatumWithValue, { @@ -172,6 +180,7 @@ const InnerBar = ({ opacity: number transform: string width: number + textAnchor: BarItemProps['style']['textAnchor'] } >(barsWithValue, { keys: bar => bar.key, @@ -181,8 +190,7 @@ const InnerBar = ({ height: 0, labelColor: getLabelColor(bar) as string, labelOpacity: 0, - labelX: bar.width / 2, - labelY: bar.height / 2, + ...computeLabelLayout(bar.width, bar.height), transform: `translate(${bar.x}, ${bar.y + bar.height})`, width: bar.width, ...(layout === 'vertical' @@ -199,8 +207,7 @@ const InnerBar = ({ height: bar.height, labelColor: getLabelColor(bar) as string, labelOpacity: 1, - labelX: bar.width / 2, - labelY: bar.height / 2, + ...computeLabelLayout(bar.width, bar.height), transform: `translate(${bar.x}, ${bar.y})`, width: bar.width, }), @@ -210,8 +217,7 @@ const InnerBar = ({ height: bar.height, labelColor: getLabelColor(bar) as string, labelOpacity: 1, - labelX: bar.width / 2, - labelY: bar.height / 2, + ...computeLabelLayout(bar.width, bar.height), transform: `translate(${bar.x}, ${bar.y})`, width: bar.width, }), @@ -221,15 +227,15 @@ const InnerBar = ({ height: 0, labelColor: getLabelColor(bar) as string, labelOpacity: 0, - labelX: bar.width / 2, + ...computeLabelLayout(bar.width, bar.height), labelY: 0, transform: `translate(${bar.x}, ${bar.y + bar.height})`, width: bar.width, ...(layout === 'vertical' ? {} : { + ...computeLabelLayout(bar.width, bar.height), labelX: 0, - labelY: bar.height / 2, height: bar.height, transform: `translate(${bar.x}, ${bar.y})`, width: 0, @@ -257,6 +263,8 @@ const InnerBar = ({ ariaLabel: barAriaLabel, ariaLabelledBy: barAriaLabelledBy, ariaDescribedBy: barAriaDescribedBy, + ariaHidden: barAriaHidden, + ariaDisabled: barAriaDisabled, }), [ borderRadius, @@ -274,6 +282,8 @@ const InnerBar = ({ barAriaLabel, barAriaLabelledBy, barAriaDescribedBy, + barAriaHidden, + barAriaDisabled, ] ) diff --git a/packages/bar/src/BarCanvas.tsx b/packages/bar/src/BarCanvas.tsx index e884ee5048..27459641e9 100644 --- a/packages/bar/src/BarCanvas.tsx +++ b/packages/bar/src/BarCanvas.tsx @@ -36,6 +36,7 @@ import { renderLegendToCanvas } from '@nivo/legends' import { useTooltip } from '@nivo/tooltip' import { useBar } from './hooks' import { BarTotalsData } from './compute/totals' +import { useComputeLabelLayout } from './compute/common' type InnerBarCanvasProps = Omit< BarCanvasProps, @@ -102,6 +103,9 @@ const InnerBarCanvas = ({ gridXValues, gridYValues, + labelPosition = canvasDefaultProps.labelPosition, + labelOffset = canvasDefaultProps.labelOffset, + layers = canvasDefaultProps.layers as BarCanvasLayer[], renderBar = ( ctx, @@ -114,6 +118,9 @@ const InnerBarCanvas = ({ label, labelColor, shouldRenderLabel, + labelX, + labelY, + textAnchor, } ) => { ctx.fillStyle = color @@ -150,9 +157,9 @@ const InnerBarCanvas = ({ if (shouldRenderLabel) { ctx.textBaseline = 'middle' - ctx.textAlign = 'center' + ctx.textAlign = textAnchor === 'middle' ? 'center' : textAnchor ctx.fillStyle = labelColor - ctx.fillText(label, x + width / 2, y + height / 2) + ctx.fillText(label, x + labelX, y + labelY) } }, @@ -311,6 +318,7 @@ const InnerBarCanvas = ({ ) const formatValue = useValueFormatter(valueFormat) + const computeLabelLayout = useComputeLabelLayout(layout, reverse, labelPosition, labelOffset) useEffect(() => { const ctx = canvasEl.current?.getContext('2d') @@ -375,6 +383,7 @@ const InnerBarCanvas = ({ label: getLabel(bar.data), labelColor: getLabelColor(bar) as string, shouldRenderLabel: shouldRenderBarLabel(bar), + ...computeLabelLayout(bar.width, bar.height), }) }) } else if (layer === 'legends') { @@ -436,6 +445,7 @@ const InnerBarCanvas = ({ barTotals, enableTotals, formatValue, + computeLabelLayout, ]) const handleMouseHover = useCallback( diff --git a/packages/bar/src/BarItem.tsx b/packages/bar/src/BarItem.tsx index 7ac2cf789f..52d7742358 100644 --- a/packages/bar/src/BarItem.tsx +++ b/packages/bar/src/BarItem.tsx @@ -17,6 +17,7 @@ export const BarItem = ({ labelY, transform, width, + textAnchor, }, borderRadius, @@ -36,6 +37,8 @@ export const BarItem = ({ ariaLabel, ariaLabelledBy, ariaDescribedBy, + ariaDisabled, + ariaHidden, }: BarItemProps) => { const theme = useTheme() const { showTooltipFromEvent, showTooltipAt, hideTooltip } = useTooltip() @@ -93,6 +96,8 @@ export const BarItem = ({ aria-label={ariaLabel ? ariaLabel(data) : undefined} aria-labelledby={ariaLabelledBy ? ariaLabelledBy(data) : undefined} aria-describedby={ariaDescribedBy ? ariaDescribedBy(data) : undefined} + aria-disabled={ariaDisabled ? ariaDisabled(data) : undefined} + aria-hidden={ariaHidden ? ariaHidden(data) : undefined} onMouseEnter={isInteractive ? handleMouseEnter : undefined} onMouseMove={isInteractive ? handleTooltip : undefined} onMouseLeave={isInteractive ? handleMouseLeave : undefined} @@ -104,7 +109,7 @@ export const BarItem = ({ >(data: }, {}) as Exclude export const coerceValue = (value: T) => [value, Number(value)] as const + +export type BarLabelLayout = { + labelX: number + labelY: number + textAnchor: 'start' | 'middle' | 'end' +} + +/** + * Compute the label position and alignment based on a given position and offset. + */ +export function useComputeLabelLayout( + layout: BarCommonProps['layout'] = defaultProps.layout, + reverse: BarCommonProps['reverse'] = defaultProps.reverse, + labelPosition: BarCommonProps['labelPosition'] = defaultProps.labelPosition, + labelOffset: BarCommonProps['labelOffset'] = defaultProps.labelOffset +): (width: number, height: number) => BarLabelLayout { + return (width: number, height: number) => { + // If the chart is reversed, we want to make sure the offset is also reversed + const computedLabelOffset = labelOffset * (reverse ? -1 : 1) + + if (layout === 'horizontal') { + let x = width / 2 + if (labelPosition === 'start') { + x = reverse ? width : 0 + } else if (labelPosition === 'end') { + x = reverse ? 0 : width + } + return { + labelX: x + computedLabelOffset, + labelY: height / 2, + textAnchor: labelPosition === 'middle' ? 'middle' : reverse ? 'end' : 'start', + } + } else { + let y = height / 2 + if (labelPosition === 'start') { + y = reverse ? 0 : height + } else if (labelPosition === 'end') { + y = reverse ? height : 0 + } + return { + labelX: width / 2, + labelY: y - computedLabelOffset, + textAnchor: 'middle', + } + } + } +} diff --git a/packages/bar/src/props.ts b/packages/bar/src/props.ts index 98c53fcf72..b9940fe5da 100644 --- a/packages/bar/src/props.ts +++ b/packages/bar/src/props.ts @@ -28,6 +28,8 @@ export const defaultProps = { enableLabel: true, label: 'formattedValue', + labelPosition: 'middle' as const, + labelOffset: 0, labelSkipWidth: 0, labelSkipHeight: 0, labelTextColor: { from: 'theme', theme: 'labels.text.fill' }, diff --git a/packages/bar/src/types.ts b/packages/bar/src/types.ts index ec545851ed..22a4ab5756 100644 --- a/packages/bar/src/types.ts +++ b/packages/bar/src/types.ts @@ -16,6 +16,7 @@ import { InheritedColorConfig, OrdinalColorScaleConfig } from '@nivo/colors' import { LegendProps } from '@nivo/legends' import { AnyScale, ScaleSpec, ScaleBandSpec } from '@nivo/scales' import { SpringValues } from '@react-spring/web' +import { BarLabelLayout } from './compute/common' export interface BarDatum { [key: string]: string | number @@ -165,6 +166,7 @@ export interface BarItemProps opacity: number transform: string width: number + textAnchor: 'start' | 'middle' }> label: string @@ -174,6 +176,8 @@ export interface BarItemProps ariaLabel?: BarSvgProps['barAriaLabel'] ariaLabelledBy?: BarSvgProps['barAriaLabelledBy'] ariaDescribedBy?: BarSvgProps['barAriaDescribedBy'] + ariaHidden?: BarSvgProps['barAriaHidden'] + ariaDisabled?: BarSvgProps['barAriaDisabled'] } export type RenderBarProps = Omit< @@ -185,10 +189,13 @@ export type RenderBarProps = Omit< | 'ariaLabel' | 'ariaLabelledBy' | 'ariaDescribedBy' -> & { - borderColor: string - labelColor: string -} + | 'ariaHidden' + | 'ariaDisabled' +> & + BarLabelLayout & { + borderColor: string + labelColor: string + } export interface BarTooltipProps extends ComputedDatum { color: string @@ -230,6 +237,8 @@ export type BarCommonProps = { enableLabel: boolean label: PropertyAccessor, string> + labelPosition: 'start' | 'middle' | 'end' + labelOffset: number labelFormat: string | LabelFormatter labelSkipWidth: number labelSkipHeight: number @@ -293,6 +302,8 @@ export type BarSvgProps = Partial ) => React.AriaAttributes['aria-describedby'] + barAriaHidden?: (data: ComputedDatum) => React.AriaAttributes['aria-hidden'] + barAriaDisabled?: (data: ComputedDatum) => React.AriaAttributes['aria-disabled'] }> export type BarCanvasProps = Partial> & diff --git a/packages/bar/tests/Bar.test.tsx b/packages/bar/tests/Bar.test.tsx index 1fe68574c7..e4d4f4dd1d 100644 --- a/packages/bar/tests/Bar.test.tsx +++ b/packages/bar/tests/Bar.test.tsx @@ -2,6 +2,7 @@ import { mount } from 'enzyme' import { create, act, ReactTestRenderer, type ReactTestInstance } from 'react-test-renderer' import { LegendSvg, LegendSvgItem } from '@nivo/legends' import { Bar, BarDatum, BarItemProps, ComputedDatum, BarItem, BarTooltip, BarTotals } from '../' +import { useComputeLabelLayout } from '../src/compute/common' type IdValue = { id: string @@ -771,6 +772,84 @@ describe('totals layer', () => { }) }) +describe('labelPosition', () => { + it.each` + labelPosition | layout | expected + ${'start'} | ${'vertical'} | ${200} + ${'middle'} | ${'vertical'} | ${100} + ${'end'} | ${'vertical'} | ${0} + ${'start'} | ${'horizontal'} | ${0} + ${'middle'} | ${'horizontal'} | ${100} + ${'end'} | ${'horizontal'} | ${200} + `( + 'should position labels correctly on $layout charts when labelPosition=$labelPosition', + ({ labelPosition, layout, expected }) => { + const instance = create( + + ).root + + for (const bar of instance.findAllByType(BarItem)) { + const { labelX, labelY } = bar.props.style + if (layout === 'vertical') { + expect(labelY.animation.to).toBe(expected) + } else { + expect(labelX.animation.to).toBe(expected) + } + } + } + ) +}) + +describe('useComputeLabelLayout', () => { + it.each` + labelPosition | layout | offset | reverse | expectedValue | expectedTextAnchor + ${'start'} | ${'vertical'} | ${0} | ${false} | ${200} | ${'middle'} + ${'middle'} | ${'vertical'} | ${0} | ${false} | ${100} | ${'middle'} + ${'end'} | ${'vertical'} | ${0} | ${false} | ${0} | ${'middle'} + ${'start'} | ${'horizontal'} | ${0} | ${false} | ${0} | ${'start'} + ${'middle'} | ${'horizontal'} | ${0} | ${false} | ${100} | ${'middle'} + ${'end'} | ${'horizontal'} | ${0} | ${false} | ${200} | ${'start'} + ${'middle'} | ${'vertical'} | ${-10} | ${false} | ${110} | ${'middle'} + ${'middle'} | ${'vertical'} | ${10} | ${false} | ${90} | ${'middle'} + ${'middle'} | ${'horizontal'} | ${-10} | ${false} | ${90} | ${'middle'} + ${'middle'} | ${'horizontal'} | ${10} | ${false} | ${110} | ${'middle'} + ${'start'} | ${'vertical'} | ${0} | ${true} | ${0} | ${'middle'} + ${'middle'} | ${'vertical'} | ${0} | ${true} | ${100} | ${'middle'} + ${'end'} | ${'vertical'} | ${0} | ${true} | ${200} | ${'middle'} + ${'start'} | ${'horizontal'} | ${0} | ${true} | ${200} | ${'end'} + ${'middle'} | ${'horizontal'} | ${0} | ${true} | ${100} | ${'middle'} + ${'end'} | ${'horizontal'} | ${0} | ${true} | ${0} | ${'end'} + ${'middle'} | ${'vertical'} | ${-10} | ${true} | ${90} | ${'middle'} + ${'middle'} | ${'vertical'} | ${10} | ${true} | ${110} | ${'middle'} + ${'middle'} | ${'horizontal'} | ${-10} | ${true} | ${110} | ${'middle'} + ${'middle'} | ${'horizontal'} | ${10} | ${true} | ${90} | ${'middle'} + `( + 'should compute the correct label layout for (layout: $layout, labelPosition: $labelPosition, offset: $offset, reverse: $reverse)', + ({ labelPosition, layout, offset, reverse, expectedValue, expectedTextAnchor }) => { + const computeLabelLayout = useComputeLabelLayout(layout, reverse, labelPosition, offset) + const { labelX, labelY, textAnchor } = computeLabelLayout(200, 200) + if (layout === 'vertical') { + expect(labelY).toBe(expectedValue) + } else { + expect(labelX).toBe(expectedValue) + } + expect(textAnchor).toBe(expectedTextAnchor) + } + ) +}) + describe('tooltip', () => { it('should render a tooltip when hovering a slice', () => { let component: ReactTestRenderer diff --git a/storybook/stories/bar/Bar.stories.tsx b/storybook/stories/bar/Bar.stories.tsx index 2c9ddb5f6f..fae22968b2 100644 --- a/storybook/stories/bar/Bar.stories.tsx +++ b/storybook/stories/bar/Bar.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react' import { generateCountriesData, sets } from '@nivo/generators' import { random, range } from 'lodash' import { useTheme } from '@nivo/core' -import { Bar, BarDatum, BarItemProps } from '@nivo/bar' +import { Bar, BarCanvas, BarDatum, BarItemProps } from '@nivo/bar' import { AxisTickProps } from '@nivo/axes' const meta: Meta = { @@ -298,6 +298,19 @@ export const WithTotals: Story = { render: () => , } +export const WithTopLabels: Story = { + render: () => ( + + ), +} + const DataGenerator = (initialIndex, initialState) => { let index = initialIndex let state = initialState diff --git a/website/src/data/components/bar/meta.yml b/website/src/data/components/bar/meta.yml index 989f3706c5..d13f37f38d 100644 --- a/website/src/data/components/bar/meta.yml +++ b/website/src/data/components/bar/meta.yml @@ -36,6 +36,8 @@ Bar: link: bar--with-annotations - label: Using totals link: bar--with-totals + - label: Using top labels + link: bar--with-top-labels description: | Bar chart which can display multiple data series, stacked or side by side. Also supports both vertical and horizontal layout, with negative values descending diff --git a/website/src/data/components/bar/props.ts b/website/src/data/components/bar/props.ts index 2e5cd406b4..c4582c2c65 100644 --- a/website/src/data/components/bar/props.ts +++ b/website/src/data/components/bar/props.ts @@ -416,6 +416,39 @@ const props: ChartProperty[] = [ control: { type: 'inheritedColor' }, group: 'Labels', }, + { + key: 'labelPosition', + help: 'Defines the position of the label relative to its bar.', + type: `'start' | 'middle' | 'end'`, + flavors: allFlavors, + required: false, + defaultValue: svgDefaultProps.labelPosition, + control: { + type: 'radio', + choices: [ + { label: 'start', value: 'start' }, + { label: 'middle', value: 'middle' }, + { label: 'end', value: 'end' }, + ], + columns: 3, + }, + group: 'Labels', + }, + { + key: 'labelOffset', + help: 'Defines the vertical or horizontal (depends on layout) offset of the label.', + type: 'number', + flavors: ['svg', 'canvas', 'api'], + required: false, + defaultValue: svgDefaultProps.labelOffset, + control: { + type: 'range', + unit: 'px', + min: -16, + max: 16, + }, + group: 'Labels', + }, { key: 'enableTotals', help: 'Enable/disable totals labels.', @@ -591,6 +624,22 @@ const props: ChartProperty[] = [ help: '[aria-describedby](https://www.w3.org/TR/wai-aria/#aria-describedby) for bar items.', type: '(data) => string', }, + { + key: 'barAriaHidden', + flavors: ['svg'], + required: false, + group: 'Accessibility', + help: '[aria-hidden](https://www.w3.org/TR/wai-aria/#aria-hidden) for bar items.', + type: '(data) => boolean', + }, + { + key: 'barAriaDisabled', + flavors: ['svg'], + required: false, + group: 'Accessibility', + help: '[aria-disabled](https://www.w3.org/TR/wai-aria/#aria-disabled) for bar items.', + type: '(data) => boolean', + }, ] export const groups = groupProperties(props) diff --git a/website/src/pages/bar/api.tsx b/website/src/pages/bar/api.tsx index 198fe61915..742cabab31 100644 --- a/website/src/pages/bar/api.tsx +++ b/website/src/pages/bar/api.tsx @@ -117,6 +117,8 @@ const BarApi = () => { from: 'color', modifiers: [['darker', 1.6]], }, + labelPosition: 'middle', + labelOffset: 0, }} /> diff --git a/website/src/pages/bar/canvas.js b/website/src/pages/bar/canvas.js index f053bd1d53..55ac6e11f3 100644 --- a/website/src/pages/bar/canvas.js +++ b/website/src/pages/bar/canvas.js @@ -97,6 +97,8 @@ const initialProperties = { from: 'color', modifiers: [['darker', 1.6]], }, + labelPosition: 'middle', + labelOffset: 0, isInteractive: true, 'custom tooltip example': false, diff --git a/website/src/pages/bar/index.js b/website/src/pages/bar/index.js index e7c91f2d13..c440ea6afa 100644 --- a/website/src/pages/bar/index.js +++ b/website/src/pages/bar/index.js @@ -115,6 +115,8 @@ const initialProperties = { from: 'color', modifiers: [['darker', 1.6]], }, + labelPosition: 'middle', + labelOffset: 0, legends: [ {