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';