-
Notifications
You must be signed in to change notification settings - Fork 53
fix(carousel): pagination carousel - accessibility improvements #2278
base: master
Are you sure you want to change the base?
Changes from 13 commits
757ce6d
c979062
1cb3a01
0104d59
3639ce7
8027a40
9ea3d70
d25bbca
0d8fe8d
4c8063e
6993079
14730c8
12b94e5
fc6abda
553cbbe
7cdfd95
b113e78
a5a4ac4
75a3e55
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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'. | ||
|
@@ -17,11 +25,18 @@ import * as keyboardKey from 'keyboard-key' | |
const carouselBehavior: Accessibility<CarouselBehaviorProps> = props => ({ | ||
attributes: { | ||
root: { | ||
role: 'region', | ||
role: props.navigation ? undefined : 'region', | ||
'aria-roledescription': props.navigation ? undefined : props.ariaRoleDescription, | ||
'aria-label': props.navigation ? undefined : props.ariaLabel, | ||
}, | ||
itemsContainerWrapper: { | ||
'aria-live': props.ariaLiveOn ? 'polite' : 'off', | ||
}, | ||
itemsContainer: { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 && { | ||
|
@@ -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 | ||||
---|---|---|---|---|---|---|
|
@@ -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, | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
}, | ||||||
}, | ||||||
|
||||||
|
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) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
||
|
@@ -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, | ||
|
@@ -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() | ||
} | ||
}, | ||
} | ||
|
||
|
@@ -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 = [] | ||
|
@@ -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( | ||
|
@@ -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) => ({ | ||
|
@@ -390,6 +391,7 @@ class Carousel extends AutoControlledComponent<WithAsProp<CarouselProps>, Carous | |
}) | ||
) : ( | ||
<Text | ||
aria-hidden="true" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
className={Carousel.slotClassNames.pagination} | ||
content={getItemPositionText(activeIndex, items.length)} | ||
/> | ||
|
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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