Skip to content

Commit

Permalink
feat(bar): add aria attributes support to SVG bar component
Browse files Browse the repository at this point in the history
  • Loading branch information
plouc committed Aug 14, 2021
1 parent ae7d537 commit b6e930f
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 12 deletions.
13 changes: 13 additions & 0 deletions packages/bar/src/Bar.tsx
Expand Up @@ -98,6 +98,11 @@ const InnerBar = <RawDatum extends BarDatum>({
legends = svgDefaultProps.legends,

role = svgDefaultProps.role,
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
isFocusable = svgDefaultProps.isFocusable,
barAriaLabel,

initialHiddenIds,
}: InnerBarProps<RawDatum>) => {
Expand Down Expand Up @@ -272,6 +277,8 @@ const InnerBar = <RawDatum extends BarDatum>({
onMouseLeave,
getTooltipLabel,
tooltip,
isFocusable,
ariaLabel: barAriaLabel,
}),
[
borderRadius,
Expand All @@ -285,6 +292,8 @@ const InnerBar = <RawDatum extends BarDatum>({
onMouseEnter,
onMouseLeave,
tooltip,
isFocusable,
barAriaLabel,
]
)

Expand Down Expand Up @@ -416,6 +425,10 @@ const InnerBar = <RawDatum extends BarDatum>({
margin={margin}
defs={boundDefs}
role={role}
ariaLabel={ariaLabel}
ariaLabelledBy={ariaLabelledBy}
ariaDescribedBy={ariaDescribedBy}
isFocusable={isFocusable}
>
{layers.map((layer, i) => {
if (typeof layer === 'function') {
Expand Down
6 changes: 6 additions & 0 deletions packages/bar/src/BarItem.tsx
Expand Up @@ -31,6 +31,9 @@ export const BarItem = <RawDatum extends BarDatum>({
onMouseLeave,

tooltip,

isFocusable,
ariaLabel,
}: BarItemProps<RawDatum>) => {
const theme = useTheme()
const { showTooltipFromEvent, hideTooltip } = useTooltip()
Expand Down Expand Up @@ -75,6 +78,9 @@ export const BarItem = <RawDatum extends BarDatum>({
onMouseMove={isInteractive ? handleTooltip : undefined}
onMouseLeave={isInteractive ? handleMouseLeave : undefined}
onClick={isInteractive ? handleClick : undefined}
focusable={isFocusable}
tabIndex={isFocusable ? 0 : undefined}
aria-label={ariaLabel ? ariaLabel() : undefined}
/>
{shouldRenderLabel && (
<animated.text
Expand Down
1 change: 1 addition & 0 deletions packages/bar/src/props.ts
Expand Up @@ -60,6 +60,7 @@ export const svgDefaultProps = {
motionConfig: 'default',

role: 'img',
isFocusable: false,
}

export const canvasDefaultProps = {
Expand Down
13 changes: 11 additions & 2 deletions packages/bar/src/types.ts
Expand Up @@ -131,7 +131,7 @@ export type BarCanvasLayer<RawDatum> =
| BarCanvasCustomLayer<RawDatum>
export type BarLayer<RawDatum> = BarLayerId | BarCustomLayer<RawDatum>

export interface BarItemProps<RawDatum>
export interface BarItemProps<RawDatum extends BarDatum>
extends Pick<
BarCommonProps<RawDatum>,
'borderRadius' | 'borderWidth' | 'isInteractive' | 'tooltip'
Expand All @@ -158,9 +158,12 @@ export interface BarItemProps<RawDatum>

label: string
shouldRenderLabel: boolean

isFocusable: boolean
ariaLabel?: BarSvgProps<RawDatum>['barAriaLabel']
}

export type RenderBarProps<RawDatum> = Omit<
export type RenderBarProps<RawDatum extends BarDatum> = Omit<
BarItemProps<RawDatum>,
'isInteractive' | 'style' | 'tooltip'
> & {
Expand Down Expand Up @@ -254,7 +257,13 @@ export type BarSvgProps<RawDatum extends BarDatum> = Partial<BarCommonProps<RawD

initialHiddenIds: string[]
layers: BarLayer<RawDatum>[]

role: string
ariaLabel?: React.AriaAttributes['aria-label']
ariaLabelledBy?: React.AriaAttributes['aria-labelledby']
ariaDescribedBy?: React.AriaAttributes['aria-describedby']
isFocusable?: boolean
barAriaLabel?: (...data: any) => string
}>

export type BarCanvasProps<RawDatum extends BarDatum> = Partial<BarCommonProps<RawDatum>> &
Expand Down
49 changes: 49 additions & 0 deletions packages/bar/tests/Bar.test.tsx
Expand Up @@ -630,3 +630,52 @@ describe('tooltip', () => {
expect(wrapper.find(CustomTooltip).exists()).toBeTruthy()
})
})

describe('accessibility', () => {
it('should forward root aria properties to the SVG element', () => {
const wrapper = mount(
<Bar
width={500}
height={300}
data={[
{ id: 'one', A: 10, B: 13 },
{ id: 'two', A: 12, B: 9 },
]}
keys={['A', 'B']}
animate={false}
ariaLabel="Aria label"
ariaLabelledBy="AriaLabelledBy"
ariaDescribedBy="AriaDescribedBy"
/>
)

const svg = wrapper.find('svg')

expect(svg.prop('aria-label')).toBe('Aria label')
expect(svg.prop('aria-labelledby')).toBe('AriaLabelledBy')
expect(svg.prop('aria-describedby')).toBe('AriaDescribedBy')
})

it('should add an aria-label attribute to bars', () => {
const wrapper = mount(
<Bar
width={500}
height={300}
data={[
{ id: 'one', A: 10, B: 13 },
{ id: 'two', A: 12, B: 9 },
]}
keys={['A', 'B']}
animate={false}
barAriaLabel={() => `Bar aria label`}
/>
)

wrapper
.find('BarItem')
.find('rect')
.forEach(bar => {
expect(bar.prop('aria-label')).toBe('Bar aria label')
})
})
})
4 changes: 4 additions & 0 deletions packages/core/index.d.ts
Expand Up @@ -348,6 +348,10 @@ declare module '@nivo/core' {
margin: Margin
defs?: any
role?: string
ariaLabel?: React.AriaAttributes['aria-label']
ariaLabelledBy?: React.AriaAttributes['aria-labelledby']
ariaDescribedBy?: React.AriaAttributes['aria-describedby']
isFocusable?: boolean
}>
) => JSX.Element
export const SvgWrapper: SvgWrapperType
Expand Down
37 changes: 27 additions & 10 deletions packages/core/src/components/SvgWrapper.js
@@ -1,20 +1,33 @@
/*
* This file is part of the nivo project.
*
* Copyright 2016-present, Raphaël Benitte.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import PropTypes from 'prop-types'
import { Defs } from './defs'
import { useTheme } from '../theming'

const SvgWrapper = ({ width, height, margin, defs, children, role }) => {
const SvgWrapper = ({
width,
height,
margin,
defs,
children,
role,
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
isFocusable = false,
}) => {
const theme = useTheme()

return (
<svg xmlns="http://www.w3.org/2000/svg" role={role} width={width} height={height}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
role={role}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
focusable={isFocusable}
tabIndex={isFocusable ? 0 : undefined}
>
<Defs defs={defs} />
<rect width={width} height={height} fill={theme.background} />
<g transform={`translate(${margin.left},${margin.top})`}>{children}</g>
Expand All @@ -32,6 +45,10 @@ SvgWrapper.propTypes = {
defs: PropTypes.array,
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
role: PropTypes.string,
isFocusable: PropTypes.bool,
ariaLabel: PropTypes.string,
ariaLabelledBy: PropTypes.string,
ariaDescribedBy: PropTypes.string,
}

export default SvgWrapper
36 changes: 36 additions & 0 deletions website/src/data/components/bar/props.js
Expand Up @@ -590,6 +590,42 @@ const props = [
},
},
...motionProperties(['svg'], svgDefaultProps, 'react-spring'),
{
key: 'isFocusable',
flavors: ['svg'],
group: 'Accessibility',
help: 'Make the root SVG element and each bar item focusable, for keyboard navigation.',
type: 'boolean',
controlType: 'switch',
},
{
key: 'ariaLabel',
flavors: ['svg'],
group: 'Accessibility',
help: 'Main element aria-label.',
type: 'string',
},
{
key: 'ariaLabelledBy',
flavors: ['svg'],
group: 'Accessibility',
help: 'Main element aria-labelledby.',
type: 'string',
},
{
key: 'ariaDescribedBy',
flavors: ['svg'],
group: 'Accessibility',
help: 'Main element aria-describedby.',
type: 'string',
},
{
key: 'barAriaLabel',
flavors: ['svg'],
group: 'Accessibility',
help: 'Aria label for bar items.',
type: 'string | (bar) => string',
},
]

export const groups = groupProperties(props)
8 changes: 8 additions & 0 deletions website/src/pages/bar/index.js
Expand Up @@ -151,6 +151,14 @@ const initialProperties = {

animate: true,
motionConfig: 'default',

role: 'application',
isFocusable: true,
ariaLabel: 'Nivo bar chart demo',
barAriaLabel: d => {
console.log({ d })
return `aria label`
},
}

const Bar = () => {
Expand Down

0 comments on commit b6e930f

Please sign in to comment.