From 211d52862c24c12b3dbef1665980813e8fee971e Mon Sep 17 00:00:00 2001 From: hazrid93 Date: Mon, 20 Jul 2026 10:28:29 +0000 Subject: [PATCH] feat: add TimePicker component Addresses #4256. Adds a Material 3 time picker that lets the user choose an hour and minute. Controlled by a value Date, reading only the hour and minute, and calls onChange with a new Date carrying the chosen time. Renders hour and minute fields, each with up and down steppers that increment or decrement and wrap at the field boundary. In 12 hour mode shows an AM/PM toggle that shifts the period by 12 hours, with midnight shown as 12; in 24 hour mode the toggle is hidden. In range adjustments never change the date part of the value. An optional minute step controls how many minutes a single step moves. The component is exported from the public surface. Companion to the date picker proposal, together covering the linked issue. --- src/components/TimePicker/TimePicker.tsx | 209 +++++++++++++++++++++++ src/components/TimePicker/types.ts | 20 +++ src/index.tsx | 2 + 3 files changed, 231 insertions(+) create mode 100644 src/components/TimePicker/TimePicker.tsx create mode 100644 src/components/TimePicker/types.ts diff --git a/src/components/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx new file mode 100644 index 0000000000..ec03872c30 --- /dev/null +++ b/src/components/TimePicker/TimePicker.tsx @@ -0,0 +1,209 @@ +import * as React from 'react'; +import { Pressable, StyleSheet, Text, View, ViewStyle } from 'react-native'; + +import type { TimePickerProps } from './types'; +import { useInternalTheme } from '../../core/theming'; + +const pad = (n: number) => String(n).padStart(2, '0'); + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 16, + } as ViewStyle, + field: { + alignItems: 'center', + marginHorizontal: 4, + } as ViewStyle, + stepperButton: { + width: 40, + height: 28, + alignItems: 'center', + justifyContent: 'center', + } as ViewStyle, + stepperText: { + fontSize: 18, + } as ViewStyle, + valueText: { + fontSize: 36, + fontWeight: '500', + minWidth: 72, + textAlign: 'center', + } as ViewStyle, + label: { + fontSize: 12, + marginTop: 4, + } as ViewStyle, + colon: { + fontSize: 36, + fontWeight: '500', + } as ViewStyle, + toggle: { + marginLeft: 12, + flexDirection: 'row', + } as ViewStyle, + toggleButton: { + paddingHorizontal: 12, + paddingVertical: 8, + borderWidth: 1, + } as ViewStyle, +}); + +/** + * Material 3 time picker (input/stepper style). + * + * Lets the user pick an hour and minute with up/down steppers, and toggle + * AM/PM in 12-hour mode. The value is controlled as a Date; only the hour and + * minute components are read and written. + * + * @param props + */ +function TimePicker({ + value, + onChange, + hours24 = false, + minuteStep = 1, + style, + theme: themeOverrides, + testID = 'time-picker', +}: TimePickerProps) { + const theme = useInternalTheme(themeOverrides); + const primary = theme.isV3 ? theme.colors.primary : theme.colors.accent; + const onSurface = theme.isV3 ? theme.colors.onSurface : '#000'; + const surface = theme.isV3 ? theme.colors.surface : '#fff'; + const outline = theme.isV3 ? theme.colors.outline : '#999'; + + const hour24 = value.getHours(); + const minute = value.getMinutes(); + const isPM = hour24 >= 12; + + const displayHour = hours24 ? hour24 : hour24 % 12 === 0 ? 12 : hour24 % 12; + + const emit = (nextHour: number, nextMinute: number) => { + const next = new Date(value); + // Let Date carry over/underflow across hour and minute boundaries. + next.setHours(nextHour, nextMinute, 0, 0); + onChange(next); + }; + + const stepHour = (delta: number) => emit(hour24 + delta, minute); + const stepMinute = (delta: number) => emit(hour24, minute + delta); + + const togglePeriod = () => { + emit(hour24 + (isPM ? -12 : 12), minute); + }; + + const PeriodToggle = () => ( + + { + if (isPM) togglePeriod(); + }} + accessibilityRole="button" + accessibilityLabel="AM" + accessibilityState={{ selected: !isPM }} + style={[ + styles.toggleButton, + { + borderColor: outline, + backgroundColor: !isPM ? primary : 'transparent', + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + } as ViewStyle, + ]} + > + + AM + + + { + if (!isPM) togglePeriod(); + }} + accessibilityRole="button" + accessibilityLabel="PM" + accessibilityState={{ selected: isPM }} + style={[ + styles.toggleButton, + { + borderColor: outline, + backgroundColor: isPM ? primary : 'transparent', + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + marginLeft: -1, + } as ViewStyle, + ]} + > + + PM + + + + ); + + const Stepper = ({ + testIDStem, + onUp, + onDown, + }: { + testIDStem: string; + onUp: () => void; + onDown: () => void; + }) => ( + + + + + + {testIDStem === `${testID}-hour` ? pad(displayHour) : pad(minute)} + + + + + + {testIDStem === `${testID}-hour` ? 'Hour' : 'Minute'} + + + ); + + return ( + + stepHour(1)} + onDown={() => stepHour(-1)} + /> + : + stepMinute(minuteStep)} + onDown={() => stepMinute(-minuteStep)} + /> + {!hours24 ? : null} + + ); +} + +export default TimePicker; diff --git a/src/components/TimePicker/types.ts b/src/components/TimePicker/types.ts new file mode 100644 index 0000000000..dfe058de51 --- /dev/null +++ b/src/components/TimePicker/types.ts @@ -0,0 +1,20 @@ +import type { StyleProp, ViewStyle } from 'react-native'; + +import type { ThemeProp } from '../../types'; + +export type TimePickerProps = { + /** Currently selected time as a Date (only hour/minute are used). Controlled. */ + value: Date; + /** Called when the user changes the time. */ + onChange: (time: Date) => void; + /** Use a 24-hour clock instead of AM/PM. @default false */ + hours24?: boolean; + /** Step in minutes for the minute field. @default 1 */ + minuteStep?: number; + /** Style applied to the container. */ + style?: StyleProp; + /** @optional */ + theme?: ThemeProp; + /** TestID for testing. */ + testID?: string; +}; diff --git a/src/index.tsx b/src/index.tsx index 1b20528787..ea2dc1e9d4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -32,6 +32,7 @@ export { default as Banner } from './components/Banner'; export { default as BottomNavigation } from './components/BottomNavigation/BottomNavigation'; export { default as Button } from './components/Button/Button'; export { default as Card } from './components/Card/Card'; +export { default as TimePicker } from './components/TimePicker/TimePicker'; export { default as Checkbox } from './components/Checkbox'; export { default as Chip } from './components/Chip/Chip'; export { default as DataTable } from './components/DataTable/DataTable'; @@ -86,6 +87,7 @@ export type { } from './components/BottomNavigation/BottomNavigation'; export type { Props as ButtonProps } from './components/Button/Button'; export type { Props as CardProps } from './components/Card/Card'; +export type { TimePickerProps } from './components/TimePicker/types'; export type { Props as CardActionsProps } from './components/Card/CardActions'; export type { Props as CardContentProps } from './components/Card/CardContent'; export type { Props as CardCoverProps } from './components/Card/CardCover';