diff --git a/README.md b/README.md index 7ce2e87..89b64e0 100644 --- a/README.md +++ b/README.md @@ -13,37 +13,37 @@ Works with Expo and bare React Native apps ✅ Includes iOS-style haptic and audio feedback 🍏 - - [Demos 📱](#demos-) - - [Installation 🚀](#installation-) - - [Peer Dependencies 👶](#peer-dependencies-) - - [Linear Gradient](#linear-gradient) - - [Masked View](#masked-view) - - [Examples 😎](#examples-) - - [Timer Picker Modal (Dark Mode) 🌚](#timer-picker-modal-dark-mode-) - - [Timer Picker Modal (Light Mode) 🌞](#timer-picker-modal-light-mode-) - - [Timer Picker Modal with Custom Buttons 🎨](#timer-picker-modal-with-custom-buttons-) - - [Timer Picker with Transparent Fade-Out (Dark Mode) 🌒](#timer-picker-with-transparent-fade-out-dark-mode-) - - [Timer Picker with Customisation (Light Mode) 🌔](#timer-picker-with-customisation-light-mode-) - - [Props 💅](#props-) - - [TimerPicker ⏲️](#timerpicker-️) - - [Custom Styles 👗](#custom-styles-) - - [Performance](#performance) - - [Custom FlatList](#custom-flatlist) - - [TimerPickerModal ⏰](#timerpickermodal-) - - [Custom Styles 👕](#custom-styles--1) - - [Methods 🔄](#methods-) - - [TimerPicker](#timerpicker) - - [TimerPickerModal](#timerpickermodal) - - [Picker Feedback 📳🔉](#picker-feedback-) - - [Audio Feedack](#audio-feedack) - - [Haptic Feedback](#haptic-feedback) - - [Feedback Example](#feedback-example) - - [Expo-Specific Audio/Haptic Feedback (DEPRECATED)](#expo-specific-audiohaptic-feedback-deprecated) - - [Contributing 🧑‍🤝‍🧑](#contributing-) - - [Dev Setup](#dev-setup) - - [GitHub Guidelines](#github-guidelines) - - [Limitations ⚠](#limitations-) - - [License 📝](#license-) +- [Demos 📱](#demos-) +- [Installation 🚀](#installation-) + - [Peer Dependencies 👶](#peer-dependencies-) + - [Linear Gradient](#linear-gradient) + - [Masked View](#masked-view) +- [Examples 😎](#examples-) + - [Timer Picker Modal (Dark Mode) 🌚](#timer-picker-modal-dark-mode-) + - [Timer Picker Modal (Light Mode) 🌞](#timer-picker-modal-light-mode-) + - [Timer Picker Modal with Custom Buttons 🎨](#timer-picker-modal-with-custom-buttons-) + - [Timer Picker with Transparent Fade-Out (Dark Mode) 🌒](#timer-picker-with-transparent-fade-out-dark-mode-) + - [Timer Picker with Customisation (Light Mode) 🌔](#timer-picker-with-customisation-light-mode-) +- [Props 💅](#props-) + - [TimerPicker ⏲️](#timerpicker-️) + - [Custom Styles 👗](#custom-styles-) + - [Performance](#performance) + - [Custom FlatList](#custom-flatlist) + - [TimerPickerModal ⏰](#timerpickermodal-) + - [Custom Styles 👕](#custom-styles--1) +- [Methods 🔄](#methods-) + - [TimerPicker](#timerpicker) + - [TimerPickerModal](#timerpickermodal) +- [Picker Feedback 📳🔉](#picker-feedback-) + - [Audio Feedack](#audio-feedack) + - [Haptic Feedback](#haptic-feedback) + - [Feedback Example](#feedback-example) + - [Expo-Specific Audio/Haptic Feedback (DEPRECATED)](#expo-specific-audiohaptic-feedback-deprecated) +- [Contributing 🧑‍🤝‍🧑](#contributing-) + - [Dev Setup](#dev-setup) + - [GitHub Guidelines](#github-guidelines) +- [Limitations ⚠](#limitations-) +- [License 📝](#license-)
@@ -527,69 +527,71 @@ return ( | Prop | Description | Type | Default | Required | | :---------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------: | :------: | -| onDurationChange | Callback when the duration changes | `(duration: { days: number, hours: number, minutes: number, seconds: number }) => void` | - | false | -| initialValue | Initial value for the picker | `{ days?: number, hours?: number, minutes?: number, seconds?: number }` | - | false | +| accessibilityLabel | Accessibility label for the entire picker component | String | - | false | +| accessibilityLabels | Custom accessibility labels for each picker column. Supports `days`, `hours`, `minutes`, `seconds`, `picker`, and `hint` keys for internationalization | `{ days?: string, hours?: string, minutes?: string, seconds?: string, picker?: string, hint?: string }` | - | false | +| aggressivelyGetLatestDuration | Set to True to ask DurationScroll to aggressively update the latestDuration ref | Boolean | false | false | +| allowFontScaling | Allow font in the picker to scale with accessibility settings | Boolean | false | false | +| amLabel | Set the AM label if using the 12-hour picker | String | am | false | +| dayInterval | The interval between values on the days picker | Number | 1 | false | +| dayLabel | Label for the days picker | String \| React.ReactElement | d | false | +| dayLimit | Limit on the days it is possible to select | `{ max?: Number, min?: Number }` | - | false | +| daysPickerIsDisabled | Disable the days picker | Boolean | false | false | +| decelerationRate | Set how quickly the picker decelerates after the user lifts their finger | 'fast', 'normal', or Number | 0.88 | false | +| disableInfiniteScroll | Disable the infinite scroll feature | Boolean | false | false | +| FlatList | FlatList component used internally to implement each picker (day, hour, minutes and seconds). More info [below](#custom-flatlist) | [react-native](https://reactnative.dev/docs/flatlist).FlatList | `FlatList` from `react-native` | false | | hideDays | Hide the days picker | Boolean | true | false | | hideHours | Hide the hours picker | Boolean | false | false | | hideMinutes | Hide the minutes picker | Boolean | false | false | | hideSeconds | Hide the seconds picker | Boolean | false | false | -| daysPickerIsDisabled | Disable the days picker | Boolean | false | false | -| hoursPickerIsDisabled | Disable the hours picker | Boolean | false | false | -| minutesPickerIsDisabled | Disable the minutes picker | Boolean | false | false | -| secondsPickerIsDisabled | Disable the seconds picker | Boolean | false | false | -| dayLimit | Limit on the days it is possible to select | `{ max?: Number, min?: Number }` | - | false | +| hourInterval | The interval between values on the hours picker | Number | 1 | false | +| hourLabel | Label for the hours picker | String \| React.ReactElement | h | false | | hourLimit | Limit on the hours it is possible to select | `{ max?: Number, min?: Number }` | - | false | -| minuteLimit | Limit on the minutes it is possible to select | `{ max?: Number, min?: Number }` | - | false | -| secondLimit | Limit on the seconds it is possible to select | `{ max?: Number, min?: Number }` | - | false | -| maximumDays | The highest value on the days picker | Number | 23 | false | +| hoursPickerIsDisabled | Disable the hours picker | Boolean | false | false | +| initialValue | Initial value for the picker | `{ days?: number, hours?: number, minutes?: number, seconds?: number }` | - | false | +| maximumDays | The highest value on the days picker | Number | 30 | false | | maximumHours | The highest value on the hours picker | Number | 23 | false | | maximumMinutes | The highest value on the minutes picker | Number | 59 | false | | maximumSeconds | The highest value on the seconds picker | Number | 59 | false | -| dayInterval | The interval between values on the days picker | Number | 1 | false | -| hourInterval | The interval between values on the hours picker | Number | 1 | false | | minuteInterval | The interval between values on the minutes picker | Number | 1 | false | -| secondInterval | The interval between values on the seconds picker | Number | 1 | false | -| dayLabel | Label for the days picker | String \| React.ReactElement | d | false | -| hourLabel | Label for the hours picker | String \| React.ReactElement | h | false | | minuteLabel | Label for the minutes picker | String \| React.ReactElement | m | false | -| secondLabel | Label for the seconds picker | String \| React.ReactElement | s | false | +| minuteLimit | Limit on the minutes it is possible to select | `{ max?: Number, min?: Number }` | - | false | +| minutesPickerIsDisabled | Disable the minutes picker | Boolean | false | false | +| onDurationChange | Callback when the duration changes | `(duration: { days: number, hours: number, minutes: number, seconds: number }) => void` | - | false | | padDaysWithZero | Pad single-digit days in the picker with a zero | Boolean | false | false | | padHoursWithZero | Pad single-digit hours in the picker with a zero | Boolean | false | false | | padMinutesWithZero | Pad single-digit minutes in the picker with a zero | Boolean | true | false | | 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 | -| 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 | | pmLabel | Set the PM label if using the 12-hour picker | String | pm | false | | repeatDayNumbersNTimes | Set the number of times the list of days is repeated in the picker | Number | 3 | false | | repeatHourNumbersNTimes | Set the number of times the list of hours is repeated in the picker | Number | 7 | false | | repeatMinuteNumbersNTimes | Set the number of times the list of minutes is repeated in the picker | Number | 3 | false | | repeatSecondNumbersNTimes | Set the number of times the list of seconds is repeated in the picker | Number | 3 | false | -| disableInfiniteScroll | Disable the infinite scroll feature | Boolean | false | false | +| secondInterval | The interval between values on the seconds picker | Number | 1 | false | +| secondLabel | Label for the seconds picker | String \| React.ReactElement | s | false | +| secondLimit | Limit on the seconds it is possible to select | `{ max?: Number, min?: Number }` | - | false | +| secondsPickerIsDisabled | Disable the seconds picker | Boolean | false | false | +| use12HourPicker | Switch the hour picker to 12-hour format with an AM / PM label | Boolean | false | false | | LinearGradient | [Linear Gradient Component (required for picker fade-out)](#linear-gradient) | [expo-linear-gradient](https://www.npmjs.com/package/expo-linear-gradient).LinearGradient or [react-native-linear-gradient](https://www.npmjs.com/package/react-native-linear-gradient).default | - | false | | MaskedView | [Masked View Component (required for picker fade-out on transparent background)](#masked-view) | [@react-native-masked-view/masked-view](https://www.npmjs.com/package/@react-native-masked-view/masked-view).default | - | false | -| FlatList | FlatList component used internally to implement each picker (day, hour, minutes and seconds). More info [below](#custom-flatlist) | [react-native](https://reactnative.dev/docs/flatlist).FlatList | `FlatList` from `react-native` | false | | pickerFeedback | [Callback for providing audio/haptic feedback](#picker-feedback-) (fired whenever the picker ticks over a value) | `() => void \| Promise ` | - | false | -| Haptics (DEPRECATED) | [Expo Haptics Namespace](#expo-specific-audiohaptic-feedback-deprecated) (please use pickerFeedback instead) | [expo-haptics](https://www.npmjs.com/package/expo-haptics) | - | false | -| Audio (DEPRECATED) | [Expo AV Audio Class](#expo-specific-audiohaptic-feedback-deprecated) | [expo-av](https://www.npmjs.com/package/expo-av).Audio (please use pickerFeedback instead) | - | false | -| clickSoundAsset (DEPRECATED) | Custom sound asset for click sound (please use pickerFeedback instead), was required for offline click sound - download default [here](https://drive.google.com/uc?export=download&id=10e1YkbNsRh-vGx1jmS1Nntz8xzkBp4_I) | require(.../somefolderpath) or {uri: www.someurl} | - | false | | pickerContainerProps | Props for the picker container | `React.ComponentProps` | - | false | | pickerGradientOverlayProps | Props for the gradient overlay (supply a different `locations` array to adjust its position) overlays | `Partial` | - | false | | styles | Custom styles for the timer picker | [CustomTimerPickerStyles](#custom-styles-) | - | false | -| decelerationRate | Set how quickly the picker decelerates after the user lifts their finger | 'fast', 'normal', or Number | 0.88 | false | +| Haptics (DEPRECATED) | [Expo Haptics Namespace](#expo-specific-audiohaptic-feedback-deprecated) (please use pickerFeedback instead) | [expo-haptics](https://www.npmjs.com/package/expo-haptics) | - | false | +| Audio (DEPRECATED) | [Expo AV Audio Class](#expo-specific-audiohaptic-feedback-deprecated) | [expo-av](https://www.npmjs.com/package/expo-av).Audio (please use pickerFeedback instead) | - | false | +| clickSoundAsset (DEPRECATED) | Custom sound asset for click sound (please use pickerFeedback instead), was required for offline click sound - download default [here](https://drive.google.com/uc?export=download&id=10e1YkbNsRh-vGx1jmS1Nntz8xzkBp4_I) | require(.../somefolderpath) or {uri: www.someurl} | - | false | -#### Custom Styles 👗 +#### Custom Styles 👗 The component should look good straight out of the box, but you can use these styles to make it fit in with your App's theme: -| Style Prop | Description | Type | -| :------------------------------------: | :------------------------------------------------------------------- | :--------------------------------------: | -| theme | Theme of the component | "light" \| "dark" | -| backgroundColor | Main background color | string | -| text | Base text style | TextStyle | -| labelOffsetPercentage | Percentage offset for horizonal label positioning relative to the picker | number | +| Style Prop | Description | Type | +| :-------------------: | :----------------------------------------------------------------------- | :---------------: | +| theme | Theme of the component | "light" \| "dark" | +| backgroundColor | Main background color | string | +| text | Base text style | TextStyle | +| labelOffsetPercentage | Percentage offset for horizonal label positioning relative to the picker | number | For deeper style customization, you can supply the following custom styles to adjust the component in any way. These are applied on top of the default styling so take a look at those [styles](src/components/TimerPicker/styles.ts) if something isn't adjusting in the way you'd expect. @@ -643,6 +645,56 @@ 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 behavior accordingly. No additional configuration is required for basic accessibility support. + +**Internationalization:** + +You can customize the accessibility labels for internationalization: + +```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 +- The component uses the `useScreenReaderEnabled` hook to automatically detect screen reader state +- Values announced respect your `padWithZero` settings (e.g., "05" vs "5") +- 12-hour format announcements include AM/PM (e.g., "03 PM") + +You can also use the `useScreenReaderEnabled` hook in your own components: + +```jsx +import { useScreenReaderEnabled } from "react-native-timer-picker"; + +function MyComponent() { + const isScreenReaderEnabled = useScreenReaderEnabled(); + + // Adjust your UI based on screen reader state + return ...; +} +``` + ### TimerPickerModal ⏰ The TimerPickerModal component accepts all [TimerPicker props](#timerpicker-️), and the below additional props. @@ -672,16 +724,16 @@ The TimerPickerModal component accepts all [TimerPicker props](#timerpicker-️) The following custom styles can be supplied to re-style the component in any way. You can also supply all of the styles specified in [CustomTimerPickerStyles](#custom-styles-). These are applied on top of the default styling so take a look at those [styles](src/components/TimerPickerModal/styles.ts) if something isn't adjusting in the way you'd expect. -| Style Prop | Description | Type | -| :--------------: | :----------------------------------------- | :-------: | -| container | Style for the modal container | ViewStyle | -| contentContainer | Style for the modal content's container | ViewStyle | -| buttonContainer | Style for the container for the buttons | ViewStyle | -| button | General style for both buttons | TextStyle | -| cancelButton | Style for the cancel button | TextStyle | -| confirmButton | Style for the confirm button | TextStyle | -| modalTitle | Style for the title of the modal | TextStyle | -| ... | Supply any of [TimerPicker's custom styles]((#custom-styles-)) | - | +| Style Prop | Description | Type | +| :--------------: | :--------------------------------------------------------------- | :-------: | +| container | Style for the modal container | ViewStyle | +| contentContainer | Style for the modal content's container | ViewStyle | +| buttonContainer | Style for the container for the buttons | ViewStyle | +| button | General style for both buttons | TextStyle | +| cancelButton | Style for the cancel button | TextStyle | +| confirmButton | Style for the confirm button | TextStyle | +| modalTitle | Style for the title of the modal | TextStyle | +| ... | Supply any of [TimerPicker's custom styles](<(#custom-styles-)>) | - |
diff --git a/src/components/DurationScroll/DurationScroll.tsx b/src/components/DurationScroll/DurationScroll.tsx index a07b45c..660eeb2 100644 --- a/src/components/DurationScroll/DurationScroll.tsx +++ b/src/components/DurationScroll/DurationScroll.tsx @@ -8,7 +8,12 @@ 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, @@ -36,6 +41,8 @@ const keyExtractor = (item: any, index: number) => index.toString(); const DurationScroll = forwardRef( (props, ref) => { const { + accessibilityHint, + accessibilityLabel, aggressivelyGetLatestDuration, allowFontScaling = false, amLabel, @@ -44,11 +51,13 @@ const DurationScroll = forwardRef( decelerationRate = 0.88, disableInfiniteScroll = false, FlatList = RNFlatList, + formatValue, Haptics, initialValue = 0, interval, is12HourPicker, isDisabled, + isScreenReaderEnabled = false, label, limit, LinearGradient, @@ -469,6 +478,81 @@ const DurationScroll = forwardRef( [styles.pickerItemContainer.height] ); + const handleAccessibilityAction = useCallback( + (event: { nativeEvent: { actionName: string } }) => { + const { actionName } = event.nativeEvent; + + if (actionName === "increment") { + let newValue = latestDuration.current + interval; + + // Wrap around to minimum if exceeding maximum + if (newValue > adjustedLimited.max) { + newValue = adjustedLimited.min; + } + + flatListRef.current?.scrollToIndex({ + animated: true, + index: getInitialScrollIndex({ + disableInfiniteScroll, + interval, + numberOfItems, + padWithNItems, + repeatNumbersNTimes: safeRepeatNumbersNTimes, + value: newValue, + }), + }); + latestDuration.current = newValue; + + // Announce the new value to screen readers + const announcement = formatValue + ? formatValue(newValue) + : String(newValue); + AccessibilityInfo.announceForAccessibilityWithOptions( + announcement, + { + queue: false, + } + ); + } else if (actionName === "decrement") { + let newValue = latestDuration.current - interval; + + // Wrap around to maximum if going below minimum + if (newValue < adjustedLimited.min) { + newValue = adjustedLimited.max; + } + + flatListRef.current?.scrollToIndex({ + animated: true, + index: getInitialScrollIndex({ + disableInfiniteScroll, + interval, + numberOfItems, + padWithNItems, + repeatNumbersNTimes: safeRepeatNumbersNTimes, + value: newValue, + }), + }); + latestDuration.current = newValue; + + // Announce the new value to screen readers + const announcement = formatValue + ? formatValue(newValue) + : String(newValue); + AccessibilityInfo.announceForAccessibility(announcement); + } + }, + [ + adjustedLimited.max, + adjustedLimited.min, + disableInfiniteScroll, + formatValue, + interval, + numberOfItems, + padWithNItems, + safeRepeatNumbersNTimes, + ] + ); + useImperativeHandle(ref, () => ({ reset: (options) => { flatListRef.current?.scrollToIndex({ @@ -495,37 +579,83 @@ const DurationScroll = forwardRef( const renderContent = useMemo(() => { return ( <> - i * styles.pickerItemContainer.height)} - style={styles.durationScrollFlatList} - testID="duration-scroll-flatlist" - viewabilityConfigCallbackPairs={ - viewabilityConfigCallbackPairs + accessibilityRole={ + isScreenReaderEnabled ? "adjustable" : undefined } - windowSize={numberOfItemsToShow} - /> + accessibilityValue={ + isScreenReaderEnabled + ? { + text: formatValue + ? formatValue(latestDuration.current) + : String(latestDuration.current), + } + : undefined + } + accessible={isScreenReaderEnabled ? true : false} + onAccessibilityAction={handleAccessibilityAction}> + i * styles.pickerItemContainer.height + )} + style={styles.durationScrollFlatList} + testID="duration-scroll-flatlist" + viewabilityConfigCallbackPairs={ + viewabilityConfigCallbackPairs + } + windowSize={numberOfItemsToShow} + /> + {typeof label === "string" ? ( @@ -542,12 +672,17 @@ const DurationScroll = forwardRef( ); }, [ FlatList, + accessibilityHint, + accessibilityLabel, allowFontScaling, decelerationRate, flatListRenderKey, + formatValue, getItemLayout, + handleAccessibilityAction, initialScrollIndex, isDisabled, + isScreenReaderEnabled, label, numberOfItemsToShow, numbersForFlatList, diff --git a/src/components/DurationScroll/types.ts b/src/components/DurationScroll/types.ts index 9b51cc9..7405837 100644 --- a/src/components/DurationScroll/types.ts +++ b/src/components/DurationScroll/types.ts @@ -11,16 +11,20 @@ export interface DurationScrollProps { Haptics?: any; LinearGradient?: any; MaskedView?: any; + accessibilityHint?: string; + accessibilityLabel?: string; aggressivelyGetLatestDuration: boolean; allowFontScaling?: boolean; amLabel?: string; clickSoundAsset?: SoundAsset; decelerationRate?: number | "normal" | "fast"; disableInfiniteScroll?: boolean; + formatValue?: (value: number) => string; initialValue?: number; interval: number; is12HourPicker?: boolean; isDisabled?: boolean; + isScreenReaderEnabled?: boolean; label?: string | React.ReactElement; limit?: Limit; maximumValue: number; diff --git a/src/components/TimerPicker/TimerPicker.tsx b/src/components/TimerPicker/TimerPicker.tsx index 652a3f1..3502a22 100644 --- a/src/components/TimerPicker/TimerPicker.tsx +++ b/src/components/TimerPicker/TimerPicker.tsx @@ -10,6 +10,8 @@ import React, { import { View } from "react-native"; import { getSafeInitialValue } from "../../utils/getSafeInitialValue"; +import { padNumber } from "../../utils/padNumber"; +import { useScreenReaderEnabled } from "../../utils/useScreenReaderEnabled"; import DurationScroll from "../DurationScroll"; import type { DurationScrollRef } from "../DurationScroll"; @@ -19,6 +21,8 @@ import type { TimerPickerProps, TimerPickerRef } from "./types"; const TimerPicker = forwardRef( (props, ref) => { const { + accessibilityLabel, + accessibilityLabels, aggressivelyGetLatestDuration = false, allowFontScaling = false, amLabel = "am", @@ -66,6 +70,8 @@ const TimerPicker = forwardRef( ...otherProps } = props; + const isScreenReaderEnabled = useScreenReaderEnabled(); + useEffect(() => { if (otherProps.Audio) { console.warn( @@ -98,6 +104,28 @@ const TimerPicker = forwardRef( return Math.round(padWithNItems); }, [hideHours, padWithNItems]); + // Format functions for accessibility announcements + const formatDayValue = (value: number) => + padDaysWithZero ? padNumber(value) : String(value); + + const formatHourValue = (value: number) => { + if (use12HourPicker) { + const hour12 = + value === 0 ? 12 : value > 12 ? value - 12 : value; + const period = value < 12 ? amLabel : pmLabel; + return padHoursWithZero + ? `${padNumber(hour12)} ${period}` + : `${hour12} ${period}`; + } + return padHoursWithZero ? padNumber(value) : String(value); + }; + + const formatMinuteValue = (value: number) => + padMinutesWithZero ? padNumber(value) : String(value); + + const formatSecondValue = (value: number) => + padSecondsWithZero ? padNumber(value) : String(value); + const safeInitialValue = useMemo( () => getSafeInitialValue({ @@ -198,19 +226,24 @@ const TimerPicker = forwardRef( return ( {!hideDays ? ( ( {!hideHours ? ( ( amLabel={amLabel} decelerationRate={decelerationRate} disableInfiniteScroll={disableInfiniteScroll} + formatValue={formatHourValue} initialValue={safeInitialValue.hours} interval={hourInterval} is12HourPicker={use12HourPicker} isDisabled={hoursPickerIsDisabled} + isScreenReaderEnabled={isScreenReaderEnabled} label={ hourLabel ?? (!use12HourPicker ? "h" : undefined) } @@ -261,15 +300,21 @@ const TimerPicker = forwardRef( {!hideMinutes ? ( ( {!hideSeconds ? ( { + const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); + + useEffect(() => { + // Check if screen reader is enabled on mount + AccessibilityInfo.isScreenReaderEnabled().then( + (screenReaderEnabled) => { + setIsScreenReaderEnabled(screenReaderEnabled); + } + ); + + // Subscribe to screen reader state changes + const subscription = AccessibilityInfo.addEventListener( + "screenReaderChanged", + (screenReaderEnabled) => { + setIsScreenReaderEnabled(screenReaderEnabled); + } + ); + + return () => { + subscription?.remove(); + }; + }, []); + + return isScreenReaderEnabled; +};