♿️(frontend) keep carousel nav buttons focusable at first and last slide
use aria-disabled to prevent focus loss when reaching slide limits
This commit is contained in:
@@ -25,6 +25,7 @@ and this project adheres to
|
|||||||
- ♿️(frontend) fix focus ring on tab container components #1012
|
- ♿️(frontend) fix focus ring on tab container components #1012
|
||||||
- ♿️(frontend) upgrade join meeting modal accessibility #1027
|
- ♿️(frontend) upgrade join meeting modal accessibility #1027
|
||||||
- ⬆️(python) bump minimal required python version to 3.13 #1033
|
- ⬆️(python) bump minimal required python version to 3.13 #1033
|
||||||
|
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { styled } from '@/styled-system/jsx'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||||
import { useRef, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
const Heading = styled('h2', {
|
const Heading = styled('h2', {
|
||||||
base: {
|
base: {
|
||||||
@@ -144,6 +145,21 @@ type Slide = {
|
|||||||
isAvailableInBeta?: boolean
|
isAvailableInBeta?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const carouselNavButton = css({
|
||||||
|
_focusVisible: {
|
||||||
|
outline: '2px solid var(--colors-focus-ring) !important',
|
||||||
|
outlineOffset: '1px',
|
||||||
|
},
|
||||||
|
_disabled: {
|
||||||
|
color: 'greyscale.400',
|
||||||
|
cursor: 'default',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
_pressed: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// todo - optimize how images are imported
|
// todo - optimize how images are imported
|
||||||
const SLIDES: Slide[] = [
|
const SLIDES: Slide[] = [
|
||||||
{
|
{
|
||||||
@@ -162,30 +178,39 @@ const SLIDES: Slide[] = [
|
|||||||
|
|
||||||
export const IntroSlider = () => {
|
export const IntroSlider = () => {
|
||||||
const [slideIndex, setSlideIndex] = useState(0)
|
const [slideIndex, setSlideIndex] = useState(0)
|
||||||
const prevButtonRef = useRef<HTMLButtonElement>(null)
|
|
||||||
const nextButtonRef = useRef<HTMLButtonElement>(null)
|
|
||||||
const focusTargetRef = useRef<'prev' | 'next' | null>(null)
|
|
||||||
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
|
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const NUMBER_SLIDES = SLIDES.length
|
const NUMBER_SLIDES = SLIDES.length
|
||||||
|
|
||||||
const prevSlideIndex = slideIndex - 1
|
const goPrev = () => {
|
||||||
const nextSlideIndex = slideIndex + 1
|
if (slideIndex === 0) return
|
||||||
|
const newIndex = slideIndex - 1
|
||||||
|
setSlideIndex(newIndex)
|
||||||
|
announce(
|
||||||
|
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
|
||||||
|
'polite',
|
||||||
|
'global'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const previousAriaLabel =
|
const goNext = () => {
|
||||||
slideIndex > 0
|
if (slideIndex === NUMBER_SLIDES - 1) return
|
||||||
? t('previous.withPosition', {
|
const newIndex = slideIndex + 1
|
||||||
target: prevSlideIndex + 1,
|
setSlideIndex(newIndex)
|
||||||
total: NUMBER_SLIDES,
|
announce(
|
||||||
})
|
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
|
||||||
: t('previous.label')
|
'polite',
|
||||||
const nextAriaLabel =
|
'global'
|
||||||
slideIndex < NUMBER_SLIDES - 1
|
)
|
||||||
? t('next.withPosition', {
|
}
|
||||||
target: nextSlideIndex + 1,
|
|
||||||
total: NUMBER_SLIDES,
|
const ariaLabelParams = {
|
||||||
})
|
current: slideIndex + 1,
|
||||||
: t('next.label')
|
total: NUMBER_SLIDES,
|
||||||
|
}
|
||||||
|
const previousAriaLabel = t('previous.labelWithPosition', ariaLabelParams)
|
||||||
|
const nextAriaLabel = t('next.labelWithPosition', ariaLabelParams)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
@@ -203,16 +228,12 @@ export const IntroSlider = () => {
|
|||||||
<ButtonContainer>
|
<ButtonContainer>
|
||||||
<ButtonVerticalCenter>
|
<ButtonVerticalCenter>
|
||||||
<Button
|
<Button
|
||||||
ref={prevButtonRef}
|
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
square
|
square
|
||||||
className="carousel-nav-button"
|
className={carouselNavButton}
|
||||||
aria-label={previousAriaLabel}
|
aria-label={previousAriaLabel}
|
||||||
onPress={() => {
|
aria-disabled={slideIndex === 0}
|
||||||
focusTargetRef.current = 'prev'
|
onPress={goPrev}
|
||||||
setSlideIndex(prevSlideIndex)
|
|
||||||
}}
|
|
||||||
isDisabled={slideIndex == 0}
|
|
||||||
>
|
>
|
||||||
<RiArrowLeftSLine />
|
<RiArrowLeftSLine />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -220,7 +241,11 @@ export const IntroSlider = () => {
|
|||||||
</ButtonContainer>
|
</ButtonContainer>
|
||||||
<SlideContainer>
|
<SlideContainer>
|
||||||
{SLIDES.map((slide, index) => (
|
{SLIDES.map((slide, index) => (
|
||||||
<Slide visible={index == slideIndex} key={index}>
|
<Slide
|
||||||
|
aria-hidden={index !== slideIndex}
|
||||||
|
visible={index === slideIndex}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
<Image src={slide.src} alt="" role="presentation" />
|
<Image src={slide.src} alt="" role="presentation" />
|
||||||
<TextAnimation visible={index == slideIndex}>
|
<TextAnimation visible={index == slideIndex}>
|
||||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||||
@@ -232,16 +257,12 @@ export const IntroSlider = () => {
|
|||||||
<ButtonContainer>
|
<ButtonContainer>
|
||||||
<ButtonVerticalCenter>
|
<ButtonVerticalCenter>
|
||||||
<Button
|
<Button
|
||||||
ref={nextButtonRef}
|
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
square
|
square
|
||||||
className="carousel-nav-button"
|
className={carouselNavButton}
|
||||||
aria-label={nextAriaLabel}
|
aria-label={nextAriaLabel}
|
||||||
onPress={() => {
|
aria-disabled={slideIndex === NUMBER_SLIDES - 1}
|
||||||
focusTargetRef.current = 'next'
|
onPress={goNext}
|
||||||
setSlideIndex(nextSlideIndex)
|
|
||||||
}}
|
|
||||||
isDisabled={slideIndex == NUMBER_SLIDES - 1}
|
|
||||||
>
|
>
|
||||||
<RiArrowRightSLine />
|
<RiArrowRightSLine />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -252,21 +273,11 @@ export const IntroSlider = () => {
|
|||||||
className={css({
|
className={css({
|
||||||
marginTop: '0.5rem',
|
marginTop: '0.5rem',
|
||||||
display: { base: 'none', xsm: 'block' },
|
display: { base: 'none', xsm: 'block' },
|
||||||
|
|
||||||
})}
|
})}
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-atomic="true"
|
|
||||||
>
|
>
|
||||||
<span className="sr-only">
|
{SLIDES.map((_, index) => (
|
||||||
{t('slidePosition', {
|
<Dot key={index} selected={index == slideIndex} />
|
||||||
current: slideIndex + 1,
|
))}
|
||||||
total: NUMBER_SLIDES,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
{SLIDES.map((_, index) => (
|
|
||||||
<Dot key={index} selected={index == slideIndex} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,12 +32,12 @@
|
|||||||
"introSlider": {
|
"introSlider": {
|
||||||
"previous": {
|
"previous": {
|
||||||
"label": "Vorherige Folie",
|
"label": "Vorherige Folie",
|
||||||
"withPosition": "Vorherige Folie anzeigen ({{target}} von {{total}})",
|
"labelWithPosition": "Vorherige Folie ({{current}} von {{total}})",
|
||||||
"tooltip": "Vorherige Folie"
|
"tooltip": "Vorherige Folie"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"label": "Nächste Folie",
|
"label": "Nächste Folie",
|
||||||
"withPosition": "Nächste Folie anzeigen ({{target}} von {{total}})",
|
"labelWithPosition": "Nächste Folie ({{current}} von {{total}})",
|
||||||
"tooltip": "Nächste Folie"
|
"tooltip": "Nächste Folie"
|
||||||
},
|
},
|
||||||
"beta": {
|
"beta": {
|
||||||
@@ -59,4 +59,4 @@
|
|||||||
"carouselLabel": "Einführungs-Diashow",
|
"carouselLabel": "Einführungs-Diashow",
|
||||||
"slidePosition": "Folie {{current}} von {{total}}"
|
"slidePosition": "Folie {{current}} von {{total}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,12 @@
|
|||||||
"introSlider": {
|
"introSlider": {
|
||||||
"previous": {
|
"previous": {
|
||||||
"label": "Previous slide",
|
"label": "Previous slide",
|
||||||
"withPosition": "Go to previous slide ({{target}} of {{total}})",
|
"labelWithPosition": "Previous slide ({{current}} of {{total}})",
|
||||||
"tooltip": "Previous slide"
|
"tooltip": "Previous slide"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"label": "Next slide",
|
"label": "Next slide",
|
||||||
"withPosition": "Go to next slide ({{target}} of {{total}})",
|
"labelWithPosition": "Next slide ({{current}} of {{total}})",
|
||||||
"tooltip": "Next slide"
|
"tooltip": "Next slide"
|
||||||
},
|
},
|
||||||
"beta": {
|
"beta": {
|
||||||
@@ -59,4 +59,4 @@
|
|||||||
"carouselLabel": "Introduction slideshow",
|
"carouselLabel": "Introduction slideshow",
|
||||||
"slidePosition": "Slide {{current}} of {{total}}"
|
"slidePosition": "Slide {{current}} of {{total}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,12 +33,12 @@
|
|||||||
"carouselLabel": "Diaporama de présentation",
|
"carouselLabel": "Diaporama de présentation",
|
||||||
"previous": {
|
"previous": {
|
||||||
"label": "Diapositive précédente",
|
"label": "Diapositive précédente",
|
||||||
"withPosition": "Afficher la diapositive précédente ({{target}} sur {{total}})",
|
"labelWithPosition": "Diapositive précédente ({{current}} sur {{total}})",
|
||||||
"tooltip": "Diapositive précédente"
|
"tooltip": "Diapositive précédente"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"label": "Diapositive suivante",
|
"label": "Diapositive suivante",
|
||||||
"withPosition": "Afficher la diapositive suivante ({{target}} sur {{total}})",
|
"labelWithPosition": "Diapositive suivante ({{current}} sur {{total}})",
|
||||||
"tooltip": "Diapositive suivante"
|
"tooltip": "Diapositive suivante"
|
||||||
},
|
},
|
||||||
"slidePosition": "Diapositive {{current}} sur {{total}}",
|
"slidePosition": "Diapositive {{current}} sur {{total}}",
|
||||||
|
|||||||
@@ -32,12 +32,12 @@
|
|||||||
"introSlider": {
|
"introSlider": {
|
||||||
"previous": {
|
"previous": {
|
||||||
"label": "Vorige dia",
|
"label": "Vorige dia",
|
||||||
"withPosition": "Vorige dia tonen ({{target}} van {{total}})",
|
"labelWithPosition": "Vorige dia ({{current}} van {{total}})",
|
||||||
"tooltip": "Vorige dia"
|
"tooltip": "Vorige dia"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"label": "Volgende dia",
|
"label": "Volgende dia",
|
||||||
"withPosition": "Volgende dia tonen ({{target}} van {{total}})",
|
"labelWithPosition": "Volgende dia ({{current}} van {{total}})",
|
||||||
"tooltip": "Volgende dia"
|
"tooltip": "Volgende dia"
|
||||||
},
|
},
|
||||||
"beta": {
|
"beta": {
|
||||||
@@ -59,4 +59,4 @@
|
|||||||
"carouselLabel": "Introductie-diavoorstelling",
|
"carouselLabel": "Introductie-diavoorstelling",
|
||||||
"slidePosition": "Dia {{current}} van {{total}}"
|
"slidePosition": "Dia {{current}} van {{total}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,6 @@ body,
|
|||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fallback for NVDA / screen readers: ensure focus ring on :focus when
|
|
||||||
focus-visible detection fails (e.g. carousel nav buttons) */
|
|
||||||
.carousel-nav-button:focus {
|
|
||||||
outline: 2px solid var(--colors-focus-ring) !important;
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user