diff --git a/components/doc/carousel/accessibilitydoc.js b/components/doc/carousel/accessibilitydoc.js index 93bc109c61..d8bef5720a 100644 --- a/components/doc/carousel/accessibilitydoc.js +++ b/components/doc/carousel/accessibilitydoc.js @@ -1,120 +1,116 @@ -import { DevelopmentSection } from '@/components/doc/common/developmentsection'; - import { DocSectionText } from '@/components/doc/common/docsectiontext'; import Link from 'next/link'; export function AccessibilityDoc() { return ( - - -

Screen Reader

-

- Carousel uses region role and since any attribute is passed to the main container element, attributes such as aria-label and aria-roledescription can be used as well. The slides container has aria-live{' '} - attribute set as "polite" if carousel is not in autoplay mode, otherwise "off" would be the value in autoplay. -

+ +

Screen Reader

+

+ Carousel uses region role and since any attribute is passed to the main container element, attributes such as aria-label and aria-roledescription can be used as well. The slides container has aria-live{' '} + attribute set as "polite" if carousel is not in autoplay mode, otherwise "off" would be the value in autoplay. +

-

- A slide has a group role with an aria-label that refers to the aria.slideNumber property of the locale API. Similarly aria.slide is used as the aria-roledescription of the - item. Inactive slides are hidden from the readers with aria-hidden. -

+

+ A slide has a group role with an aria-label that refers to the aria.slideNumber property of the locale API. Similarly aria.slide is used as the aria-roledescription of the item. + Inactive slides are hidden from the readers with aria-hidden. +

-

- Next and Previous navigators are button elements with aria-label attributes referring to the aria.nextPageLabel and aria.firstPageLabel properties of the locale API by default - respectively, you may still use your own aria roles and attributes as any valid attribute is passed to the button elements implicitly by using nextButtonProps and prevButtonProps. -

+

+ Next and Previous navigators are button elements with aria-label attributes referring to the aria.nextPageLabel and aria.firstPageLabel properties of the locale API by default + respectively, you may still use your own aria roles and attributes as any valid attribute is passed to the button elements implicitly by using nextButtonProps and prevButtonProps. +

-

- Quick navigation elements are button elements with an aria-label attribute referring to the aria.pageLabel of the locale API. Current page is marked with aria-current. -

+

+ Quick navigation elements are button elements with an aria-label attribute referring to the aria.pageLabel of the locale API. Current page is marked with aria-current. +

-

Next/Prev Keyboard Support

-
- - - - - - - - - - - - - - - - - - - - - -
KeyFunction
- tab - Moves focus through interactive elements in the carousel.
- enter - Activates navigation.
- space - Activates navigation.
-
+

Next/Prev Keyboard Support

+
+ + + + + + + + + + + + + + + + + + + + + +
KeyFunction
+ tab + Moves focus through interactive elements in the carousel.
+ enter + Activates navigation.
+ space + Activates navigation.
+
-

Quick Navigation Keyboard Support

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KeyFunction
- tab - Moves focus through the active slide link.
- enter - Activates the focused slide link.
- space - Activates the focused slide link.
- right arrow - Moves focus to the next slide link.
- left arrow - Moves focus to the previous slide link.
- home - Moves focus to the first slide link.
- end - Moves focus to the last slide link.
-
-
-
+

Quick Navigation Keyboard Support

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyFunction
+ tab + Moves focus through the active slide link.
+ enter + Activates the focused slide link.
+ space + Activates the focused slide link.
+ right arrow + Moves focus to the next slide link.
+ left arrow + Moves focus to the previous slide link.
+ home + Moves focus to the first slide link.
+ end + Moves focus to the last slide link.
+
+ ); } diff --git a/components/lib/carousel/Carousel.js b/components/lib/carousel/Carousel.js index d7ad68a2b7..8212a46599 100644 --- a/components/lib/carousel/Carousel.js +++ b/components/lib/carousel/Carousel.js @@ -1,5 +1,5 @@ import * as React from 'react'; -import PrimeReact, { PrimeReactContext, ariaLabel } from '../api/Api'; +import PrimeReact, { PrimeReactContext, localeOption } from '../api/Api'; import { useHandleStyle } from '../componentbase/ComponentBase'; import { useMountEffect, usePrevious, useResizeListener, useUpdateEffect } from '../hooks/Hooks'; import { ChevronDownIcon } from '../icons/chevrondown'; @@ -14,9 +14,14 @@ const CarouselItem = React.memo((props) => { const { ptm, cx } = props; const key = props.className && props.className === 'p-carousel-item-cloned' ? 'itemCloned' : 'item'; const content = props.template(props.item); + const itemClonedProps = mergeProps( { className: cx(key, { itemProps: props }), + role: props.role, + 'aria-roledescription': props.ariaRoledescription, + 'aria-label': props.ariaLabel, + 'aria-hidden': props.ariaHidden, 'data-p-carousel-item-active': props.active, 'data-p-carousel-item-start': props.start, 'data-p-carousel-item-end': props.end @@ -56,6 +61,7 @@ export const Carousel = React.memo( const startPos = React.useRef(null); const interval = React.useRef(null); const carouselStyle = React.useRef(null); + const indicatorContent = React.useRef(null); const isRemainingItemsAdded = React.useRef(false); const responsiveOptions = React.useRef(null); const prevNumScroll = usePrevious(numScrollState); @@ -181,7 +187,7 @@ export const Carousel = React.memo( } }; - const onDotClick = (e, page) => { + const onIndicatorClick = (e, page) => { if (page > currentPage) { navForward(e, page); } else if (page < currentPage) { @@ -237,6 +243,93 @@ export const Carousel = React.memo( } }; + const onIndicatorKeydown = (event) => { + switch (event.code) { + case 'ArrowRight': + onRightKey(); + break; + + case 'ArrowLeft': + onLeftKey(); + break; + + case 'Home': + onHomeKey(); + event.preventDefault(); + break; + + case 'End': + onEndKey(); + event.preventDefault(); + break; + + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + break; + + case 'Tab': + onTabKey(); + break; + + default: + break; + } + }; + + const onRightKey = () => { + const indicators = [...DomHandler.find(indicatorContent.current, '[data-pc-section="indicator"]')]; + const activeIndex = findFocusedIndicatorIndex(); + + changedFocusedIndicator(activeIndex, activeIndex + 1 === indicators.length ? indicators.length - 1 : activeIndex + 1); + }; + + const onLeftKey = () => { + const activeIndex = findFocusedIndicatorIndex(); + + changedFocusedIndicator(activeIndex, activeIndex - 1 <= 0 ? 0 : activeIndex - 1); + }; + + const onHomeKey = () => { + const activeIndex = findFocusedIndicatorIndex(); + + changedFocusedIndicator(activeIndex, 0); + }; + + const onEndKey = () => { + const indicators = [...DomHandler.find(indicatorContent.current, '[data-pc-section="indicator"]r')]; + const activeIndex = findFocusedIndicatorIndex(); + + changedFocusedIndicator(activeIndex, indicators.length - 1); + }; + + const onTabKey = () => { + const indicators = [...DomHandler.find(indicatorContent.current, '[data-pc-section="indicator"]')]; + const highlightedIndex = indicators.findIndex((ind) => DomHandler.getAttribute(ind, 'data-p-highlight') === true); + + const activeIndicator = DomHandler.findSingle(indicatorContent.current, '[data-pc-section="indicator"] > button[tabindex="0"]'); + + const activeIndex = indicators.findIndex((ind) => ind === activeIndicator.parentElement); + + indicators[activeIndex].children[0].tabIndex = '-1'; + indicators[highlightedIndex].children[0].tabIndex = '0'; + }; + + const findFocusedIndicatorIndex = () => { + const indicators = [...DomHandler.find(indicatorContent.current, '[data-pc-section="indicator"]')]; + const activeIndicator = DomHandler.findSingle(indicatorContent.current, '[data-pc-section="indicator"] > button[tabindex="0"]'); + + return indicators.findIndex((ind) => ind === activeIndicator.parentElement); + }; + + const changedFocusedIndicator = (prevInd, nextInd) => { + const indicators = [...DomHandler.find(indicatorContent.current, '[data-pc-section="indicator"]')]; + + indicators[prevInd].children[0].tabIndex = '-1'; + indicators[nextInd].children[0].tabIndex = '0'; + indicators[nextInd].children[0].focus(); + }; + const startAutoplay = () => { if (props.autoplayInterval > 0) { interval.current = setInterval(() => { @@ -410,6 +503,10 @@ export const Carousel = React.memo( }; }); + const ariaSlideNumber = (value) => { + return localeOption('aria') ? localeOption('aria').slideNumber.replace(/{slideNumber}/g, value) : undefined; + }; + const createItems = () => { if (props.value && props.value.length) { let clonedItemsForStarting = null; @@ -445,8 +542,26 @@ export const Carousel = React.memo( const isActive = firstIndex <= index && lastIndex >= index; const start = firstIndex === index; const end = lastIndex === index; - - return ; + const ariaHidden = firstIndex > index || lastIndex < index ? true : undefined; + const ariaLabel = ariaSlideNumber(index); + const ariaRoledescription = localeOption('aria') ? localeOption('aria').slide : undefined; + + return ( + + ); }); return ( @@ -507,7 +622,8 @@ export const Carousel = React.memo( const containerProps = mergeProps( { - className: classNames(props.containerClassName, cx('container')) + className: classNames(props.containerClassName, cx('container')), + 'aria-live': allowAutoplay.current ? 'polite' : 'off' }, ptm('container') ); @@ -550,7 +666,8 @@ export const Carousel = React.memo( className: cx('previousButton', { isDisabled }), onClick: (e) => navBackward(e), disabled: isDisabled, - 'aria-label': ariaLabel('previousPageLabel') + 'aria-label': localeOption('aria') ? localeOption('aria').previousPageLabel : undefined, + 'data-pc-group-section': 'navigator' }, ptm('previousButton') ); @@ -583,7 +700,8 @@ export const Carousel = React.memo( className: cx('nextButton', { isDisabled }), onClick: (e) => navForward(e), disabled: isDisabled, - 'aria-label': ariaLabel('nextPageLabel') + 'aria-label': localeOption('aria') ? localeOption('aria').nextPageLabel : undefined, + 'data-pc-group-section': 'navigator' }, ptm('nextButton') ); @@ -599,6 +717,10 @@ export const Carousel = React.memo( return null; }; + const ariaPageLabel = (value) => { + return localeOption('aria') ? localeOption('aria').pageLabel.replace(/{page}/g, value) : undefined; + }; + const createIndicator = (index) => { const isActive = currentPage === index; @@ -623,8 +745,10 @@ export const Carousel = React.memo( { type: 'button', className: cx('indicatorButton'), - onClick: (e) => onDotClick(e, index), - 'aria-label': `${ariaLabel('pageLabel')} ${index + 1}` + tabIndex: currentPage === index ? '0' : '-1', + onClick: (e) => onIndicatorClick(e, index), + 'aria-label': ariaPageLabel(index + 1), + 'aria-current': currentPage === index ? 'page' : undefined }, getPTOptions('indicatorButton') ); @@ -648,7 +772,9 @@ export const Carousel = React.memo( const indicatorsProps = mergeProps( { - className: classNames(props.indicatorsContentClassName, cx('indicators')) + ref: indicatorContent, + className: classNames(props.indicatorsContentClassName, cx('indicators')), + onKeyDown: onIndicatorKeydown }, ptm('indicators') );