️(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:
Cyril
2026-02-26 09:27:51 +01:00
committed by aleb_the_flash
parent 4881fa20f5
commit 077cf59082
7 changed files with 71 additions and 66 deletions

View File

@@ -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

View File

@@ -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>
) )

View File

@@ -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}}"
} }
} }

View File

@@ -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}}"
} }
} }

View File

@@ -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}}",

View File

@@ -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}}"
} }
} }

View File

@@ -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;