Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 `<FlatList />` 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
<TimerPicker
accessibilityLabels={{
hours: "Hours",
minutes: "Minutes",
seconds: "Seconds",
hint: "Swipe up or down to adjust",
}}
// ... other props
/>
```

**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.
Expand Down
80 changes: 78 additions & 2 deletions src/components/DurationScroll/DurationScroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,6 +25,8 @@ const keyExtractor = (item: any, index: number) => index.toString();

const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props, ref) => {
const {
accessibilityHint,
accessibilityLabel,
aggressivelyGetLatestDuration,
allowFontScaling = false,
amLabel,
Expand All @@ -33,11 +35,13 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props
decelerationRate = 0.88,
disableInfiniteScroll = false,
FlatList = RNFlatList,
formatValue,
Haptics,
initialValue = 0,
interval,
is12HourPicker,
isDisabled,
isScreenReaderEnabled = false,
label,
limit,
LinearGradient,
Expand Down Expand Up @@ -176,6 +180,13 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props

const [clickSound, setClickSound] = useState<ExpoAvAudioInstance | null>(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<string>(() =>
formatValue ? formatValue(initialValue) : String(initialValue)
);

useEffect(() => {
// Audio prop deprecated in v2.2.0 (use pickerFeedback instead) - will be removed in a future version

Expand Down Expand Up @@ -474,6 +485,55 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((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) => {
Expand Down Expand Up @@ -507,6 +567,7 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props
data={numbersForFlatList}
decelerationRate={decelerationRate}
getItemLayout={getItemLayout}
importantForAccessibility={isScreenReaderEnabled ? "no-hide-descendants" : undefined}
initialScrollIndex={initialScrollIndex}
keyExtractor={keyExtractor}
nestedScrollEnabled
Expand All @@ -526,7 +587,13 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
windowSize={numberOfItemsToShow}
/>
<View pointerEvents="none" style={[styles.pickerLabelContainer, labelPositionStyle]}>
<View
accessible={false}
accessibilityElementsHidden={isScreenReaderEnabled}
importantForAccessibility={isScreenReaderEnabled ? "no-hide-descendants" : undefined}
pointerEvents="none"
style={[styles.pickerLabelContainer, labelPositionStyle]}
>
{typeof label === "string" ? (
<Text allowFontScaling={allowFontScaling} style={styles.pickerLabel}>
{label}
Expand All @@ -545,6 +612,7 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props
getItemLayout,
initialScrollIndex,
isDisabled,
isScreenReaderEnabled,
label,
labelPositionStyle,
numberOfItemsToShow,
Expand Down Expand Up @@ -607,6 +675,14 @@ const DurationScroll = forwardRef<DurationScrollRef, DurationScrollProps>((props

return (
<View
accessible
accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
accessibilityHint={accessibilityHint}
accessibilityLabel={accessibilityLabel}
accessibilityRole="adjustable"
accessibilityState={{ disabled: isDisabled }}
accessibilityValue={{ text: accessibilityValueText }}
onAccessibilityAction={handleAccessibilityAction}
pointerEvents={isDisabled ? "none" : undefined}
style={[
styles.durationScrollFlatListContainer,
Expand Down
4 changes: 4 additions & 0 deletions src/components/DurationScroll/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,20 @@ export interface DurationScrollProps {
aggressivelyGetLatestDuration: boolean;
allowFontScaling?: boolean;
amLabel?: string;
accessibilityHint?: string;
accessibilityLabel?: string;
Audio?: any;
clickSoundAsset?: SoundAsset;
decelerationRate?: number | "normal" | "fast";
disableInfiniteScroll?: boolean;
FlatList?: any;
formatValue?: (value: number) => string;
Haptics?: any;
initialValue?: number;
interval: number;
is12HourPicker?: boolean;
isDisabled?: boolean;
isScreenReaderEnabled?: boolean;
label?: string | React.ReactElement;
limit?: Limit;
LinearGradient?: any;
Expand Down
71 changes: 70 additions & 1 deletion src/components/TimerPicker/TimerPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
Expand All @@ -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";
Expand All @@ -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<TimerPickerRef, TimerPickerProps>((props, ref) => {
const {
accessibilityLabels,
aggressivelyGetLatestDuration = false,
allowFontScaling = false,
amLabel = "am",
Expand Down Expand Up @@ -80,6 +98,8 @@ const TimerPicker = forwardRef<TimerPickerRef, TimerPickerProps>((props, ref) =>
...otherProps
} = props;

const isScreenReaderEnabled = useScreenReaderEnabled();

useEffect(() => {
if (otherProps.Audio) {
console.warn(
Expand Down Expand Up @@ -206,17 +226,54 @@ const TimerPicker = forwardRef<TimerPickerRef, TimerPickerProps>((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 (
<View {...pickerContainerProps} style={styles.pickerContainer} testID="timer-picker">
<View
{...pickerContainerProps}
accessible={isScreenReaderEnabled ? false : undefined}
style={styles.pickerContainer}
testID="timer-picker"
>
{!hideDays ? (
<DurationScroll
ref={daysDurationScrollRef}
accessibilityHint={accessibilityLabels?.hint}
accessibilityLabel={accessibilityLabels?.days ?? "Days"}
aggressivelyGetLatestDuration={aggressivelyGetLatestDuration}
allowFontScaling={allowFontScaling}
disableInfiniteScroll={disableInfiniteScroll}
formatValue={formatDayA11y}
initialValue={safeInitialValue.days}
interval={dayInterval}
isDisabled={daysPickerIsDisabled}
isScreenReaderEnabled={isScreenReaderEnabled}
label={dayLabel ?? "d"}
limit={dayLimit}
maximumValue={maximumDays}
Expand All @@ -236,15 +293,19 @@ const TimerPicker = forwardRef<TimerPickerRef, TimerPickerProps>((props, ref) =>
{!hideHours ? (
<DurationScroll
ref={hoursDurationScrollRef}
accessibilityHint={accessibilityLabels?.hint}
accessibilityLabel={accessibilityLabels?.hours ?? "Hours"}
aggressivelyGetLatestDuration={aggressivelyGetLatestDuration}
allowFontScaling={allowFontScaling}
amLabel={amLabel}
decelerationRate={decelerationRate}
disableInfiniteScroll={disableInfiniteScroll}
formatValue={formatHourA11y}
initialValue={safeInitialValue.hours}
interval={hourInterval}
is12HourPicker={use12HourPicker}
isDisabled={hoursPickerIsDisabled}
isScreenReaderEnabled={isScreenReaderEnabled}
label={hourLabel ?? (!use12HourPicker ? "h" : undefined)}
limit={hourLimit}
maximumValue={maximumHours}
Expand All @@ -265,13 +326,17 @@ const TimerPicker = forwardRef<TimerPickerRef, TimerPickerProps>((props, ref) =>
{!hideMinutes ? (
<DurationScroll
ref={minutesDurationScrollRef}
accessibilityHint={accessibilityLabels?.hint}
accessibilityLabel={accessibilityLabels?.minutes ?? "Minutes"}
aggressivelyGetLatestDuration={aggressivelyGetLatestDuration}
allowFontScaling={allowFontScaling}
decelerationRate={decelerationRate}
disableInfiniteScroll={disableInfiniteScroll}
formatValue={formatMinuteA11y}
initialValue={safeInitialValue.minutes}
interval={minuteInterval}
isDisabled={minutesPickerIsDisabled}
isScreenReaderEnabled={isScreenReaderEnabled}
label={minuteLabel ?? "m"}
limit={minuteLimit}
maximumValue={maximumMinutes}
Expand All @@ -291,13 +356,17 @@ const TimerPicker = forwardRef<TimerPickerRef, TimerPickerProps>((props, ref) =>
{!hideSeconds ? (
<DurationScroll
ref={secondsDurationScrollRef}
accessibilityHint={accessibilityLabels?.hint}
accessibilityLabel={accessibilityLabels?.seconds ?? "Seconds"}
aggressivelyGetLatestDuration={aggressivelyGetLatestDuration}
allowFontScaling={allowFontScaling}
decelerationRate={decelerationRate}
disableInfiniteScroll={disableInfiniteScroll}
formatValue={formatSecondA11y}
initialValue={safeInitialValue.seconds}
interval={secondInterval}
isDisabled={secondsPickerIsDisabled}
isScreenReaderEnabled={isScreenReaderEnabled}
label={secondLabel ?? "s"}
limit={secondLimit}
maximumValue={maximumSeconds}
Expand Down
7 changes: 7 additions & 0 deletions src/components/TimerPicker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export interface TimerPickerRef {
}

export interface TimerPickerProps {
accessibilityLabels?: {
days?: string;
hint?: string;
hours?: string;
minutes?: string;
seconds?: string;
};
aggressivelyGetLatestDuration?: boolean;
allowFontScaling?: boolean;
amLabel?: string;
Expand Down
Loading