Skip to content
This repository was archived by the owner on Mar 4, 2020. It is now read-only.

fix(carousel): pagination carousel - accessibility improvements #2278

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react'
import { Text } from '@fluentui/react'
import { Text, Box } from '@fluentui/react'
import { link } from '../../../../utils/helpers'

import ComponentBestPractices from 'docs/src/components/ComponentBestPractices'
Expand All @@ -10,6 +10,15 @@ const doList = [
{link('reported issue', 'https://bugs.chromium.org/p/chromium/issues/detail?id=1040924')} for
details).
</Text>,
'Provide localized string of the "carousel" using `ariaRoleDescription` prop.',
'Provide label to the carousel using `ariaLabel` prop.',
<Box>
If carousel contains `navigation`:
<ul>
<li> provide label to `navigation` and to navigation item using `aria-label` attribute</li>
<li> add `aria-controls` attribute to navigation item referencing to `carouselItem` id </li>
</ul>
</Box>,
]

const CarouselBestPractices: React.FunctionComponent<{}> = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const carouselItems = [
const CarouselExample = () => (
<Carousel
ariaRoleDescription="carousel"
ariaLabel="Portrait collection"
navigation={{
'aria-label': 'people portraits',
items: carouselItems.map((item, index) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const carouselItems = [
const CarouselExample = () => (
<Carousel
ariaRoleDescription="carousel"
ariaLabel="Portrait collection"
items={carouselItems}
paddleNext={{ 'aria-label': 'go to next slide' }}
paddlePrevious={{ 'aria-label': 'go to previous slide' }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const CarouselExample = () => (
<Carousel
circular
ariaRoleDescription="carousel"
ariaLabel="Portrait collection"
navigation={{
'aria-label': 'people portraits',
items: carouselItems.map((item, index) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@ import { Accessibility } from '../../types'
import * as keyboardKey from 'keyboard-key'

/**
* @description
* Adds attribute 'role=region' to 'root' slot if 'navigation' property is false. Does not set the attribute otherwise.
* Adds attribute 'aria-roledescription' to 'root' slot if 'navigation' property is false. Does not set the attribute otherwise.
* Adds attribute 'aria-label' to 'root' slot if 'navigation' property is false. Does not set the attribute otherwise.
* Adds attribute 'aria-roledescription' to 'itemsContainer' slot if 'navigation' property is true. Does not set the attribute otherwise.
* Adds attribute 'aria-label' to 'itemsContainer' slot if 'navigation' property is true. Does not set the attribute otherwise.
*
* @specification
* Adds attribute 'role=region' to 'root' slot.
* Adds attribute 'aria-live=polite' to 'itemsContainerWrapper' slot if 'ariaLiveOn' property is true. Sets the attribute to 'off' otherwise.
* Adds attribute 'aria-hidden=true' to 'paddleNext' slot if 'navigation' property is true. Does not set the attribute otherwise.
* Adds attribute 'aria-hidden=true' to 'paddlePrevious' slot if 'navigation' property is true. Does not set the attribute otherwise.
* Adds attribute 'tabIndex=-1' to 'paddlePrevious' slot if 'navigation' property is true. Does not set the attribute otherwise.
* Adds attribute 'tabIndex=-1' to 'paddlePrevious' slot if 'navigation' property is true. Does not set the attribute otherwise.
* Adds attribute 'role=group' to 'itemsContainer' slot if 'navigation' property is true. Does not set the attribute otherwise.
* Triggers 'showNextSlideByKeyboardNavigation' action with 'ArrowRight' on 'itemsContainer'.
* Triggers 'showPreviousSlideByKeyboardNavigation' action with 'ArrowLeft' on 'itemsContainer'.
* Triggers 'showNextSlideByPaddlePress' action with 'Enter' or 'Spacebar' on 'paddleNext'.
Expand All @@ -17,11 +25,18 @@ import * as keyboardKey from 'keyboard-key'
const carouselBehavior: Accessibility<CarouselBehaviorProps> = props => ({
attributes: {
root: {
role: 'region',
role: props.navigation ? undefined : 'region',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we say that we are not setting the attribute otherwise, shouldn't we destructure these 3 attributes? Because how we are doing it now we are adding either the value or undefined, any of these options meaning that we are setting a value.

Copy link
Collaborator

@silviuaavram silviuaavram Feb 20, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also you can probably add the condition only once since it's the same one. something like

...(props.navigation && { role: 'region', 'aria-roledescription': props.ariaRoleDescription, 'aria-label': props.ariaLabel })

'aria-roledescription': props.navigation ? undefined : props.ariaRoleDescription,
'aria-label': props.navigation ? undefined : props.ariaLabel,
},
itemsContainerWrapper: {
'aria-live': props.ariaLiveOn ? 'polite' : 'off',
},
itemsContainer: {
Copy link
Collaborator

@silviuaavram silviuaavram Feb 20, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comments as above here.

role: props.navigation ? 'group' : undefined,
'aria-roledescription': props.navigation ? props.ariaRoleDescription : undefined,
'aria-label': props.navigation ? props.ariaLabel : undefined,
},

paddleNext: {
...(props.navigation && {
Expand Down Expand Up @@ -63,6 +78,8 @@ export type CarouselBehaviorProps = {
/** Element type. */
navigation: Object | Object[]
ariaLiveOn: boolean
ariaRoleDescription?: string
ariaLabel?: string
}

export default carouselBehavior
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@ import { Accessibility } from '../../types'
import * as keyboardKey from 'keyboard-key'

/**
* @description
* Adds attribute 'tabIndex=0' to 'root' slot if 'active' property and 'navigation' property is true. Sets the attribute to '-1' otherwise.
*
* @specification
* Adds attribute 'role=tabpanel' to 'root' slot if 'navigation' property is true. Sets the attribute to 'group' otherwise.
* Adds attribute 'aria-hidden=false' to 'root' slot if 'active' property is true. Sets the attribute to 'true' otherwise.
* Adds attribute 'tabIndex=0' to 'root' slot if 'active' property is true. Sets the attribute to '-1' otherwise.
* Triggers 'arrowKeysNavigationStopPropagation' action with 'ArrowRight' or 'ArrowLeft' on 'root'.
*/
const carouselItemBehavior: Accessibility<CarouselItemProps> = props => ({
attributes: {
root: {
role: props.navigation ? 'tabpanel' : 'group',
'aria-hidden': props.active ? 'false' : 'true',
tabIndex: props.active ? 0 : -1,
tabIndex: props.navigation ? (props.active ? 0 : -1) : -1,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
tabIndex: props.navigation ? (props.active ? 0 : -1) : -1,
tabIndex: (props.navigation && props.active) ? 0 : -1,

},
},

Expand Down
99 changes: 99 additions & 0 deletions packages/accessibility/test/behaviors/caroselBehavior-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { carouselBehavior } from '@fluentui/accessibility'

const roleDescription = 'carousel'
const label = 'portrait collection'

describe('carouselBehavior.ts', () => {
describe('root', () => {
test(`sets "role=region" when carousel has NO navigation`, () => {
const expectedResult = carouselBehavior({ ariaLiveOn: false, navigation: false })
expect(expectedResult.attributes.root.role).toEqual('region')
})

test('sets "aria-roledescription" when carousel has NO navigation', () => {
const expectedResult = carouselBehavior({
ariaLiveOn: false,
navigation: false,
ariaRoleDescription: roleDescription,
})
expect(expectedResult.attributes.root['aria-roledescription']).toEqual(roleDescription)
})

test('sets "aria-label" when carousel has NO navigation', () => {
const expectedResult = carouselBehavior({
ariaLiveOn: false,
navigation: false,
ariaLabel: label,
})
expect(expectedResult.attributes.root['aria-label']).toEqual(label)
})

test('do NOT set "aria-roledescription" when carousel has navigation', () => {
const expectedResult = carouselBehavior({
ariaLiveOn: false,
navigation: true,
ariaRoleDescription: roleDescription,
})
expect(expectedResult.attributes.root['aria-roledescription']).toBeUndefined()
})

test('do NOT set "aria-label" when carousel has navigation', () => {
const expectedResult = carouselBehavior({
ariaLiveOn: false,
navigation: true,
ariaLabel: label,
})
expect(expectedResult.attributes.root['aria-label']).toBeUndefined()
})

test(`do NOT set "role=region" when carousel has navigation`, () => {
const expectedResult = carouselBehavior({ ariaLiveOn: false, navigation: true })
expect(expectedResult.attributes.root.role).toBeUndefined()
})
})

describe('itemsContainer', () => {
test('sets "aria-roledescription" when carousel has navigation', () => {
const expectedResult = carouselBehavior({
ariaLiveOn: false,
navigation: true,
ariaRoleDescription: roleDescription,
})
expect(expectedResult.attributes.itemsContainer['aria-roledescription']).toEqual(
roleDescription,
)
})

test('sets "aria-label" when carousel has navigation', () => {
const expectedResult = carouselBehavior({
ariaLiveOn: false,
navigation: true,
ariaLabel: label,
})
expect(expectedResult.attributes.itemsContainer['aria-label']).toEqual(label)
})

test('do NOT set "aria-roledescription" when carousel has NO navigation', () => {
const expectedResult = carouselBehavior({
ariaLiveOn: false,
navigation: false,
ariaRoleDescription: roleDescription,
})
expect(expectedResult.attributes.itemsContainer['aria-roledescription']).toBeUndefined()
})

test('do NOT set "aria-label" when carousel has NO navigation', () => {
const expectedResult = carouselBehavior({
ariaLiveOn: false,
navigation: false,
ariaLabel: label,
})
expect(expectedResult.attributes.itemsContainer['aria-label']).toBeUndefined()
})

test(`do NOT set "role=group" when carousel has NO navigation`, () => {
const expectedResult = carouselBehavior({ ariaLiveOn: false, navigation: false })
expect(expectedResult.attributes.itemsContainer.role).toBeUndefined()
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { carouselItemBehavior } from '@fluentui/accessibility'

describe('carouselItemBehavior.ts', () => {
test('sets tabIndex="0" on root when carousel has navigation and item is visible ', () => {
const expectedResult = carouselItemBehavior({ navigation: true, active: true })
expect(expectedResult.attributes.root.tabIndex).toEqual(0)
})

test('sets tabIndex="-1" on root when carousel has navigation and item is NOT visible ', () => {
const expectedResult = carouselItemBehavior({ navigation: true, active: false })
expect(expectedResult.attributes.root.tabIndex).toEqual(-1)
})

test('sets tabIndex="-1" on root when carousel has NO navigation and item is visible', () => {
const expectedResult = carouselItemBehavior({ navigation: false, active: true })
expect(expectedResult.attributes.root.tabIndex).toEqual(-1)
})

test('sets tabIndex="-1" on root when carousel has NO navigation and item is NOT visible', () => {
const expectedResult = carouselItemBehavior({ navigation: false, active: false })
expect(expectedResult.attributes.root.tabIndex).toEqual(-1)
})
})
38 changes: 20 additions & 18 deletions packages/react/src/components/Carousel/Carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export interface CarouselProps extends UIComponentProps, ChildrenComponentProps
*/
ariaRoleDescription?: string

/**
* Sets the aria-label attribute for carousel.
*/
ariaLabel?: string

/** Specifies if the process of switching slides is circular. */
circular?: boolean

Expand Down Expand Up @@ -128,6 +133,7 @@ class Carousel extends AutoControlledComponent<WithAsProp<CarouselProps>, Carous
}),
activeIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
ariaRoleDescription: PropTypes.string,
ariaLabel: PropTypes.string,
circular: PropTypes.bool,
defaultActiveIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
getItemPositionText: PropTypes.func,
Expand Down Expand Up @@ -185,27 +191,11 @@ class Carousel extends AutoControlledComponent<WithAsProp<CarouselProps>, Carous
},
showNextSlideByPaddlePress: e => {
e.preventDefault()
const { activeIndex } = this.state
const { circular, items, navigation } = this.props

this.showNextSlide(e, false)

// if 'next' paddle will disappear, will focus 'previous' one.
if (!navigation && activeIndex >= items.length - 2 && !circular) {
this.paddlePreviousRef.current.focus()
}
},
showPreviousSlideByPaddlePress: e => {
e.preventDefault()
const { activeIndex } = this.state
const { circular, navigation } = this.props

this.showPreviousSlide(e, false)

// if 'previous' paddle will disappear, will focus 'next' one.
if (!navigation && activeIndex <= 1 && !circular) {
this.paddleNextRef.current.focus()
}
},
}

Expand Down Expand Up @@ -251,7 +241,7 @@ class Carousel extends AutoControlledComponent<WithAsProp<CarouselProps>, Carous
}

renderContent = (accessibility, styles, unhandledProps) => {
const { ariaRoleDescription, getItemPositionText, items } = this.props
const { getItemPositionText, items } = this.props
const { activeIndex, itemIds } = this.state

this.itemRefs = []
Expand All @@ -260,7 +250,6 @@ class Carousel extends AutoControlledComponent<WithAsProp<CarouselProps>, Carous
<div style={styles.itemsContainerWrapper} {...accessibility.attributes.itemsContainerWrapper}>
<div
className={Carousel.slotClassNames.itemsContainer}
aria-roledescription={ariaRoleDescription}
style={styles.itemsContainer}
{...accessibility.attributes.itemsContainer}
{...applyAccessibilityKeyHandlers(
Expand Down Expand Up @@ -294,10 +283,22 @@ class Carousel extends AutoControlledComponent<WithAsProp<CarouselProps>, Carous

showPreviousSlide = (e: React.SyntheticEvent, focusItem: boolean) => {
this.setActiveIndex(e, this.state.activeIndex - 1, focusItem)
// if 'previous' paddle will disappear, will focus 'next' one.
if (!this.props.navigation && this.state.activeIndex <= 1 && !this.props.circular) {
this.paddleNextRef.current.focus()
}
}

showNextSlide = (e: React.SyntheticEvent, focusItem: boolean) => {
this.setActiveIndex(e, this.state.activeIndex + 1, focusItem)
// if 'next' paddle will disappear, will focus 'previous' one.
if (
!this.props.navigation &&
this.state.activeIndex >= this.props.items.length - 2 &&
!this.props.circular
) {
this.paddlePreviousRef.current.focus()
}
}

handlePaddleOverrides = (predefinedProps: ButtonProps, paddleName: string) => ({
Expand Down Expand Up @@ -390,6 +391,7 @@ class Carousel extends AutoControlledComponent<WithAsProp<CarouselProps>, Carous
})
) : (
<Text
aria-hidden="true"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand. If we are hiding this then what is the point in having it in the first place?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was relevant complain that with virtual cursor navigation user hit the text twice.
I am hiding here the second text. First text appearance still stay in each tab/slide.

className={Carousel.slotClassNames.pagination}
content={getItemPositionText(activeIndex, items.length)}
/>
Expand Down