feat(SliderArea): keyboard accessibility for the comparison slider

The slider element had role="slider" and tabindex="0" but no keyboard
handler — the focus ring appeared but the slider could not be moved.

Add a keydown handler implementing the standard ARIA slider contract:
- ArrowLeft / ArrowDown — step left by 1 percent
- ArrowRight / ArrowUp — step right by 1 percent
- Shift + arrow — coarse step (10 percent)
- PageUp / PageDown — coarse step (10 percent)
- Home — jump to 0
- End — jump to 100

Bounds and step sizes extracted as named constants (SLIDER_MIN,
SLIDER_MAX, SLIDER_STEP_FINE, SLIDER_STEP_COARSE). Position updates go
through sliderSpring.target so keyboard moves animate the same way as
pointer drags.

Also adds the missing ARIA attributes that screen readers need:
- aria-valuemin / aria-valuemax (bounds)
- aria-orientation (horizontal)
This commit is contained in:
Ilia Mashkov
2026-05-25 10:57:54 +03:00
parent b5fec3a1ba
commit 410a7cd37e
@@ -70,6 +70,19 @@ const SLIDER_PERSIST_DEBOUNCE_MS = 100;
const SLIDER_PADDING_MOBILE_PX = 48; const SLIDER_PADDING_MOBILE_PX = 48;
const SLIDER_PADDING_DESKTOP_PX = 96; const SLIDER_PADDING_DESKTOP_PX = 96;
/**
* Position bounds (percent of container width).
*/
const SLIDER_MIN = 0;
const SLIDER_MAX = 100;
/**
* Fine and coarse keyboard step sizes. Shift / Page keys use the coarse
* step; bare arrow keys use the fine step.
*/
const SLIDER_STEP_FINE = 1;
const SLIDER_STEP_COARSE = 10;
const fontA = $derived(comparisonStore.fontA); const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB); const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady); const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
@@ -130,6 +143,46 @@ function startDragging(e: PointerEvent) {
handleMove(e); handleMove(e);
} }
/**
* Keyboard control for the comparison slider. Implements the standard
* ARIA slider keyboard contract: arrows step the position, Shift+arrow
* and PageUp/PageDown jump by the coarse step, Home/End snap to bounds.
*/
function handleKeydown(e: KeyboardEvent) {
const coarse = e.shiftKey;
const step = coarse ? SLIDER_STEP_COARSE : SLIDER_STEP_FINE;
const current = sliderSpring.target;
let next = current;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowDown':
next = current - step;
break;
case 'ArrowRight':
case 'ArrowUp':
next = current + step;
break;
case 'PageDown':
next = current - SLIDER_STEP_COARSE;
break;
case 'PageUp':
next = current + SLIDER_STEP_COARSE;
break;
case 'Home':
next = SLIDER_MIN;
break;
case 'End':
next = SLIDER_MAX;
break;
default:
return;
}
e.preventDefault();
sliderSpring.target = Math.max(SLIDER_MIN, Math.min(SLIDER_MAX, next));
}
const storeSliderPosition = debounce((value: number) => { const storeSliderPosition = debounce((value: number) => {
comparisonStore.sliderPosition = value; comparisonStore.sliderPosition = value;
}, SLIDER_PERSIST_DEBOUNCE_MS); }, SLIDER_PERSIST_DEBOUNCE_MS);
@@ -271,8 +324,12 @@ const paddingClass = $derived(
role="slider" role="slider"
tabindex="0" tabindex="0"
aria-valuenow={Math.round(sliderPos)} aria-valuenow={Math.round(sliderPos)}
aria-valuemin={SLIDER_MIN}
aria-valuemax={SLIDER_MAX}
aria-orientation="horizontal"
aria-label="Font comparison slider" aria-label="Font comparison slider"
onpointerdown={startDragging} onpointerdown={startDragging}
onkeydown={handleKeydown}
class=" class="
relative w-full max-w-6xl h-full relative w-full max-w-6xl h-full
flex flex-col justify-center flex flex-col justify-center