Skip to content

Conversation

@JasperVercammen
Copy link

♿ Add VoiceOver and TalkBack Accessibility Support

Summary

Implements comprehensive screen reader accessibility for the timer picker component, enabling VoiceOver (iOS) and TalkBack (Android) users to interact with all picker columns using native swipe gestures.

What's Changed

New Files

  • src/utils/useScreenReaderEnabled.ts - Hook to detect screen reader state with automatic change detection

Modified Files

  • src/components/DurationScroll/types.ts - Added accessibility-related props
  • src/components/DurationScroll/DurationScroll.tsx - Implemented accessible picker with adjustable role
  • src/components/TimerPicker/types.ts - Added accessibility label props
  • src/components/TimerPicker/TimerPicker.tsx - Integrated screen reader detection and passed accessibility props
  • src/index.ts - Exported useScreenReaderEnabled hook
  • README.md - Documented new accessibility features and props

Features

Native Screen Reader Support

  • Pickers become "adjustable" elements when screen readers are enabled
  • Users can swipe up/down to increment/decrement values
  • Values wrap around at boundaries (e.g., 00 → 23 → 00 for hours)

Immediate Audio Feedback

  • New values announced instantly after each gesture
  • Respects formatting preferences (padWithZero, 12-hour format, etc.)

Conditional Behavior

  • Accessibility only active when screen reader is enabled
  • Normal scroll behavior preserved for sighted users
  • Prevents iOS parent-blocking-child accessibility issues

Internationalization Ready

<TimerPicker
    accessibilityLabels={{
        hours: "Heures",
        minutes: "Minutes",
        seconds: "Secondes",
        hint: "Balayez vers le haut ou le bas pour ajuster"
    }}
/>

New Props

TimerPicker / TimerPickerModal

  • accessibilityLabel?: string - Label for entire picker
  • accessibilityLabels?: { days, hours, minutes, seconds, picker, hint } - Per-column labels

Technical Implementation

  • Uses AccessibilityInfo API to detect VoiceOver/TalkBack state
  • Wraps FlatList in accessible View with accessibilityRole="adjustable"
  • Hides child elements from screen readers using importantForAccessibility="no-hide-descendants"
  • Labels explicitly hidden with accessibilityElementsHidden (iOS) and importantForAccessibility (Android)
  • Format functions passed to respect existing display preferences

Breaking Changes

None - fully backward compatible. Default behavior unchanged when screen readers are disabled.

Testing

Manual Testing Required:

  • iOS: Enable VoiceOver (Settings → Accessibility → VoiceOver)
  • Android: Enable TalkBack (Settings → Accessibility → TalkBack)
  • Verify each picker column is focusable
  • Verify swipe up/down adjusts values
  • Verify values are announced immediately
  • Verify wrap-around behavior at min/max values
  • Test with different configurations (12-hour format, custom intervals, limits)

Automated Tests:

All existing tests pass. Accessibility features don't interfere with test queries (default isScreenReaderEnabled={false}).

Documentation

Updated README with:

  • New props in TimerPicker props table
  • Dedicated "Accessibility" section with usage examples
  • Internationalization examples
  • How to use useScreenReaderEnabled hook in custom components

@troberts-28
Copy link
Owner

Hey @JasperVercammen, thank you for putting this PR together. Will review ASAP 🙌

Copy link
Owner

@troberts-28 troberts-28 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks again for the PR @JasperVercammen. There's a little bit of work to do to get this release-ready - have left you some comments. It's also be great make this complete by adding the little bits of accesibility support needed for TimerPickerModal (roles and labels for the modal components, announcement for the modal etc.)

const announcement = formatValue
? formatValue(newValue)
: String(newValue);
AccessibilityInfo.announceForAccessibility(announcement);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for not using announceForAccessibilityWithOptions with queue: false for decrementing (as for incrementing?)

isScreenReaderEnabled
? {
text: formatValue
? formatValue(latestDuration.current)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is memoized so this won't receive the latest value of this ref - you'll end up with a stale value here I think

const TimerPicker = forwardRef<TimerPickerRef, TimerPickerProps>(
(props, ref) => {
const {
accessibilityLabel,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't look like this is used?

hint?: string;
hours?: string;
minutes?: string;
picker?: string;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this is used anywhere?

viewabilityConfigCallbackPairs={
viewabilityConfigCallbackPairs
accessibilityRole={
isScreenReaderEnabled ? "adjustable" : undefined
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we all need all of these ternaries? I think a lot of these are harmless even if there's no screen reader so would be cleaner to just set them. Not certain but looks as though the only two that actually matter are: importantForAccessibility="no-hide-descendants" and accessibilityElementsHidden


// Format functions for accessibility announcements
const formatDayValue = (value: number) =>
padDaysWithZero ? padNumber(value) : String(value);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should probably include a label right (e.g. 5 days)? Otherwise the announcement would just say the number, which doesn't make it clear which picker the value refers to. I'm also not sure that we need to pad the value with a zero here; that's a visual concern and not really of any use to someone using a screen reader

const formatDayValue = (value: number) =>
padDaysWithZero ? padNumber(value) : String(value);

const formatHourValue = (value: number) => {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These format fns are all pure functions so should be moved out of the component (otherwise these will be recreated on every render, breaking the memoization of DurationScroll. I think we could probably combine them into a single formatAccessibilityValue utility fn

onMomentumScrollEnd={onMomentumScrollEnd}
onScroll={onScroll}
renderItem={renderItem}
scrollEnabled={!isDisabled}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think we probably need: accessibilityState={{ disabled: isDisabled }}


**Internationalization:**

You can customize the accessibility labels for internationalization:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessibility labels aren't for internationalization though?

ref={flatListRef}
contentContainerStyle={
styles.durationScrollFlatListContentContainer
<View
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding this extra View is potentially problematic. It could well interfere with people's layout — it has no style prop, so it'll use default flex behaviour and users can't target it with styling. There's already a wrapper View in this component in the return, I think we can just move these accessibility props there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants