️(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) upgrade join meeting modal accessibility #1027
- ⬆️(python) bump minimal required python version to 3.13 #1033
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
### Fixed

View File

@@ -2,8 +2,9 @@ import { styled } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Button } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useRef, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const Heading = styled('h2', {
base: {
@@ -144,6 +145,21 @@ type Slide = {
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
const SLIDES: Slide[] = [
{
@@ -162,30 +178,39 @@ const SLIDES: Slide[] = [
export const IntroSlider = () => {
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 announce = useScreenReaderAnnounce()
const NUMBER_SLIDES = SLIDES.length
const prevSlideIndex = slideIndex - 1
const nextSlideIndex = slideIndex + 1
const goPrev = () => {
if (slideIndex === 0) return
const newIndex = slideIndex - 1
setSlideIndex(newIndex)
announce(
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
'polite',
'global'
)
}
const previousAriaLabel =
slideIndex > 0
? t('previous.withPosition', {
target: prevSlideIndex + 1,
const goNext = () => {
if (slideIndex === NUMBER_SLIDES - 1) return
const newIndex = slideIndex + 1
setSlideIndex(newIndex)
announce(
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
'polite',
'global'
)
}
const ariaLabelParams = {
current: slideIndex + 1,
total: NUMBER_SLIDES,
})
: t('previous.label')
const nextAriaLabel =
slideIndex < NUMBER_SLIDES - 1
? t('next.withPosition', {
target: nextSlideIndex + 1,
total: NUMBER_SLIDES,
})
: t('next.label')
}
const previousAriaLabel = t('previous.labelWithPosition', ariaLabelParams)
const nextAriaLabel = t('next.labelWithPosition', ariaLabelParams)
return (
<Container
@@ -203,16 +228,12 @@ export const IntroSlider = () => {
<ButtonContainer>
<ButtonVerticalCenter>
<Button
ref={prevButtonRef}
variant="secondaryText"
square
className="carousel-nav-button"
className={carouselNavButton}
aria-label={previousAriaLabel}
onPress={() => {
focusTargetRef.current = 'prev'
setSlideIndex(prevSlideIndex)
}}
isDisabled={slideIndex == 0}
aria-disabled={slideIndex === 0}
onPress={goPrev}
>
<RiArrowLeftSLine />
</Button>
@@ -220,7 +241,11 @@ export const IntroSlider = () => {
</ButtonContainer>
<SlideContainer>
{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" />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
@@ -232,16 +257,12 @@ export const IntroSlider = () => {
<ButtonContainer>
<ButtonVerticalCenter>
<Button
ref={nextButtonRef}
variant="secondaryText"
square
className="carousel-nav-button"
className={carouselNavButton}
aria-label={nextAriaLabel}
onPress={() => {
focusTargetRef.current = 'next'
setSlideIndex(nextSlideIndex)
}}
isDisabled={slideIndex == NUMBER_SLIDES - 1}
aria-disabled={slideIndex === NUMBER_SLIDES - 1}
onPress={goNext}
>
<RiArrowRightSLine />
</Button>
@@ -252,18 +273,8 @@ export const IntroSlider = () => {
className={css({
marginTop: '0.5rem',
display: { base: 'none', xsm: 'block' },
})}
role="status"
aria-live="polite"
aria-atomic="true"
>
<span className="sr-only">
{t('slidePosition', {
current: slideIndex + 1,
total: NUMBER_SLIDES,
})}
</span>
{SLIDES.map((_, index) => (
<Dot key={index} selected={index == slideIndex} />
))}

View File

@@ -32,12 +32,12 @@
"introSlider": {
"previous": {
"label": "Vorherige Folie",
"withPosition": "Vorherige Folie anzeigen ({{target}} von {{total}})",
"labelWithPosition": "Vorherige Folie ({{current}} von {{total}})",
"tooltip": "Vorherige Folie"
},
"next": {
"label": "Nächste Folie",
"withPosition": "Nächste Folie anzeigen ({{target}} von {{total}})",
"labelWithPosition": "Nächste Folie ({{current}} von {{total}})",
"tooltip": "Nächste Folie"
},
"beta": {

View File

@@ -32,12 +32,12 @@
"introSlider": {
"previous": {
"label": "Previous slide",
"withPosition": "Go to previous slide ({{target}} of {{total}})",
"labelWithPosition": "Previous slide ({{current}} of {{total}})",
"tooltip": "Previous slide"
},
"next": {
"label": "Next slide",
"withPosition": "Go to next slide ({{target}} of {{total}})",
"labelWithPosition": "Next slide ({{current}} of {{total}})",
"tooltip": "Next slide"
},
"beta": {

View File

@@ -33,12 +33,12 @@
"carouselLabel": "Diaporama de présentation",
"previous": {
"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"
},
"next": {
"label": "Diapositive suivante",
"withPosition": "Afficher la diapositive suivante ({{target}} sur {{total}})",
"labelWithPosition": "Diapositive suivante ({{current}} sur {{total}})",
"tooltip": "Diapositive suivante"
},
"slidePosition": "Diapositive {{current}} sur {{total}}",

View File

@@ -32,12 +32,12 @@
"introSlider": {
"previous": {
"label": "Vorige dia",
"withPosition": "Vorige dia tonen ({{target}} van {{total}})",
"labelWithPosition": "Vorige dia ({{current}} van {{total}})",
"tooltip": "Vorige dia"
},
"next": {
"label": "Volgende dia",
"withPosition": "Volgende dia tonen ({{target}} van {{total}})",
"labelWithPosition": "Volgende dia ({{current}} van {{total}})",
"tooltip": "Volgende dia"
},
"beta": {

View File

@@ -30,13 +30,6 @@ body,
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) {
* {
animation: none !important;