diff --git a/README.md b/README.md index d46a364..dfb4ed8 100644 --- a/README.md +++ b/README.md @@ -564,6 +564,7 @@ return ( | padSecondsWithZero | Pad single-digit seconds in the picker with a zero | Boolean | true | false | | padWithNItems | Number of items to pad the picker with on either side | Number | 1 | false | | aggressivelyGetLatestDuration | Set to True to ask DurationScroll to aggressively update the latestDuration ref | Boolean | false | false | +| accessibilityLabels | Custom accessibility labels for each picker column. Supports `days`, `hours`, `minutes`, `seconds`, and `hint` keys | `{ days?: string, hours?: string, minutes?: string, seconds?: string, hint?: string }` | - | false | | allowFontScaling | Allow font in the picker to scale with accessibility settings | Boolean | false | false | | use12HourPicker | Switch the hour picker to 12-hour format with an AM / PM label | Boolean | false | false | | amLabel | Set the AM label if using the 12-hour picker | String | am | false | @@ -653,6 +654,42 @@ Please note that this solution does not work for all bottom-sheet components (e. **Important**: The custom component needs to have the same interface as React Native's `` in order for it to work as expected. A complete reference of the current usage can be found [here](/src/components/DurationScroll/index.tsx). +#### Accessibility ♿ + +The TimerPicker component supports VoiceOver (iOS) and TalkBack (Android) screen readers. When a screen reader is enabled, users can: + +- Navigate to each picker column (days, hours, minutes, seconds) +- Swipe up to increment the value +- Swipe down to decrement the value +- Hear immediate announcements of the new value after each adjustment + +**Basic Usage:** + +The component automatically detects when a screen reader is active and adjusts its behaviour accordingly. No additional configuration is required for basic accessibility support. + +**Custom Labels:** + +You can customise the accessibility labels for each picker column using `accessibilityLabels`: + +```jsx + +``` + +**How it works:** + +- When a screen reader is **disabled**, users interact with the picker normally by scrolling +- When a screen reader is **enabled**, each picker column becomes an "adjustable" element that responds to swipe gestures +- Screen reader announcements include the unit label (e.g. "5 hours", "30 minutes") +- 12-hour format announcements include the AM/PM period (e.g. "5 pm") + ### TimerPickerModal ⏰ The TimerPickerModal component accepts all [TimerPicker props](#timerpicker-️), and the below additional props. diff --git a/src/components/DurationScroll/DurationScroll.tsx b/src/components/DurationScroll/DurationScroll.tsx index e89775d..1901d55 100644 --- a/src/components/DurationScroll/DurationScroll.tsx +++ b/src/components/DurationScroll/DurationScroll.tsx @@ -8,7 +8,7 @@ import React, { useMemo, } from "react"; -import { View, Text, FlatList as RNFlatList } from "react-native"; +import { View, Text, FlatList as RNFlatList, AccessibilityInfo } from "react-native"; import type { ViewabilityConfigCallbackPairs, FlatListProps } from "react-native"; import { colorToRgba } from "../../utils/colorToRgba"; @@ -25,6 +25,8 @@ const keyExtractor = (item: any, index: number) => index.toString(); const DurationScroll = forwardRef((props, ref) => { const { + accessibilityHint, + accessibilityLabel, aggressivelyGetLatestDuration, allowFontScaling = false, amLabel, @@ -33,11 +35,13 @@ const DurationScroll = forwardRef((props decelerationRate = 0.88, disableInfiniteScroll = false, FlatList = RNFlatList, + formatValue, Haptics, initialValue = 0, interval, is12HourPicker, isDisabled, + isScreenReaderEnabled = false, label, limit, LinearGradient, @@ -176,6 +180,13 @@ const DurationScroll = forwardRef((props const [clickSound, setClickSound] = useState(null); + // Track the current value text for the accessibility value prop (state so it + // is always in sync — reading latestDuration.current inside a useMemo would + // be stale since refs don't trigger re-renders). + const [accessibilityValueText, setAccessibilityValueText] = useState(() => + formatValue ? formatValue(initialValue) : String(initialValue) + ); + useEffect(() => { // Audio prop deprecated in v2.2.0 (use pickerFeedback instead) - will be removed in a future version @@ -474,6 +485,55 @@ const DurationScroll = forwardRef((props [styles.pickerItemContainer.height] ); + const handleAccessibilityAction = useCallback( + (event: { nativeEvent: { actionName: string } }) => { + const { actionName } = event.nativeEvent; + + let newValue: number; + + if (actionName === "increment") { + newValue = latestDuration.current + interval; + if (newValue > adjustedLimited.max) { + newValue = adjustedLimited.min; + } + } else if (actionName === "decrement") { + newValue = latestDuration.current - interval; + if (newValue < adjustedLimited.min) { + newValue = adjustedLimited.max; + } + } else { + return; + } + + flatListRef.current?.scrollToIndex({ + animated: true, + index: getInitialScrollIndex({ + disableInfiniteScroll, + interval, + numberOfItems, + padWithNItems, + repeatNumbersNTimes: safeRepeatNumbersNTimes, + value: newValue, + }), + }); + latestDuration.current = newValue; + + const announcement = formatValue ? formatValue(newValue) : String(newValue); + setAccessibilityValueText(announcement); + AccessibilityInfo.announceForAccessibilityWithOptions(announcement, { queue: false }); + }, + [ + adjustedLimited.max, + adjustedLimited.min, + disableInfiniteScroll, + formatValue, + interval, + numberOfItems, + padWithNItems, + safeRepeatNumbersNTimes, + ] + ); + useImperativeHandle(ref, () => ({ latestDuration: latestDuration, reset: (options) => { @@ -507,6 +567,7 @@ const DurationScroll = forwardRef((props data={numbersForFlatList} decelerationRate={decelerationRate} getItemLayout={getItemLayout} + importantForAccessibility={isScreenReaderEnabled ? "no-hide-descendants" : undefined} initialScrollIndex={initialScrollIndex} keyExtractor={keyExtractor} nestedScrollEnabled @@ -526,7 +587,13 @@ const DurationScroll = forwardRef((props viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs} windowSize={numberOfItemsToShow} /> - + {typeof label === "string" ? ( {label} @@ -545,6 +612,7 @@ const DurationScroll = forwardRef((props getItemLayout, initialScrollIndex, isDisabled, + isScreenReaderEnabled, label, labelPositionStyle, numberOfItemsToShow, @@ -607,6 +675,14 @@ const DurationScroll = forwardRef((props return ( string; Haptics?: any; initialValue?: number; interval: number; is12HourPicker?: boolean; isDisabled?: boolean; + isScreenReaderEnabled?: boolean; label?: string | React.ReactElement; limit?: Limit; LinearGradient?: any; diff --git a/src/components/TimerPicker/TimerPicker.tsx b/src/components/TimerPicker/TimerPicker.tsx index 43af37c..b719786 100644 --- a/src/components/TimerPicker/TimerPicker.tsx +++ b/src/components/TimerPicker/TimerPicker.tsx @@ -1,5 +1,6 @@ import React, { forwardRef, + useCallback, useEffect, useImperativeHandle, useMemo, @@ -10,6 +11,7 @@ import React, { import { View } from "react-native"; import { getSafeInitialValue } from "../../utils/getSafeInitialValue"; +import { useScreenReaderEnabled } from "../../utils/useScreenReaderEnabled"; import DurationScroll from "../DurationScroll"; import type { DurationScrollRef } from "../DurationScroll"; import { generateStyles } from "./styles"; @@ -31,8 +33,24 @@ const resolvePerColumn = ( return value[column]; }; +// Pure utility — defined outside the component so it is never recreated on +// render and DurationScroll's memoization is not broken. +const formatAccessibilityValue = ( + value: number, + unitLabel: string, + options?: { is12HourPicker?: boolean; amLabel?: string; pmLabel?: string } +): string => { + if (options?.is12HourPicker) { + const hour12 = value === 0 ? 12 : value > 12 ? value - 12 : value; + const period = value < 12 ? options.amLabel ?? "am" : options.pmLabel ?? "pm"; + return `${hour12} ${period}`; + } + return `${value} ${unitLabel}`; +}; + const TimerPicker = forwardRef((props, ref) => { const { + accessibilityLabels, aggressivelyGetLatestDuration = false, allowFontScaling = false, amLabel = "am", @@ -80,6 +98,8 @@ const TimerPicker = forwardRef((props, ref) => ...otherProps } = props; + const isScreenReaderEnabled = useScreenReaderEnabled(); + useEffect(() => { if (otherProps.Audio) { console.warn( @@ -206,17 +226,54 @@ const TimerPicker = forwardRef((props, ref) => }, })); + // Accessibility format functions — stable references via useCallback so they + // don't break DurationScroll's memoization. No zero-padding: screen readers + // announce "5 hours", not "05 hours". + const formatDayA11y = useCallback( + (value: number) => formatAccessibilityValue(value, accessibilityLabels?.days ?? "days"), + [accessibilityLabels?.days] + ); + + const formatHourA11y = useCallback( + (value: number) => + formatAccessibilityValue(value, accessibilityLabels?.hours ?? "hours", { + is12HourPicker: use12HourPicker, + amLabel, + pmLabel, + }), + [accessibilityLabels?.hours, use12HourPicker, amLabel, pmLabel] + ); + + const formatMinuteA11y = useCallback( + (value: number) => formatAccessibilityValue(value, accessibilityLabels?.minutes ?? "minutes"), + [accessibilityLabels?.minutes] + ); + + const formatSecondA11y = useCallback( + (value: number) => formatAccessibilityValue(value, accessibilityLabels?.seconds ?? "seconds"), + [accessibilityLabels?.seconds] + ); + return ( - + {!hideDays ? ( ((props, ref) => {!hideHours ? ( ((props, ref) => {!hideMinutes ? ( ((props, ref) => {!hideSeconds ? ( ( safeInitialValue.seconds, ]); + // announce the picker to screen readers when the modal opens + useEffect(() => { + if (visible) { + AccessibilityInfo.announceForAccessibility(modalTitle ?? "Time picker"); + } + }, [visible, modalTitle]); + const hideModalHandler = () => { setSelectedDuration({ days: confirmedDuration.days, @@ -170,7 +177,7 @@ const TimerPickerModal = forwardRef( {modalTitle ? ( - + {modalTitle} ) : null} @@ -193,7 +200,12 @@ const TimerPickerModal = forwardRef( onPress: cancelHandler, }) ) : ( - + {cancelButtonText} ) @@ -203,7 +215,12 @@ const TimerPickerModal = forwardRef( onPress: confirmHandler, }) ) : ( - + {confirmButtonText} )} diff --git a/src/utils/useScreenReaderEnabled.ts b/src/utils/useScreenReaderEnabled.ts new file mode 100644 index 0000000..5428745 --- /dev/null +++ b/src/utils/useScreenReaderEnabled.ts @@ -0,0 +1,26 @@ +import { useState, useEffect } from "react"; + +import { AccessibilityInfo } from "react-native"; + +export const useScreenReaderEnabled = (): boolean => { + const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); + + useEffect(() => { + AccessibilityInfo.isScreenReaderEnabled().then((screenReaderEnabled) => { + setIsScreenReaderEnabled(screenReaderEnabled); + }); + + const subscription = AccessibilityInfo.addEventListener( + "screenReaderChanged", + (screenReaderEnabled) => { + setIsScreenReaderEnabled(screenReaderEnabled); + } + ); + + return () => { + subscription?.remove(); + }; + }, []); + + return isScreenReaderEnabled; +};