diff --git a/README.md b/README.md index d46a364..7cec30b 100644 --- a/README.md +++ b/README.md @@ -566,6 +566,7 @@ return ( | 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 | +| separateAmPmPicker | When `use12HourPicker` is true, render AM/PM as a dedicated scrollable column after seconds (instead of appending it to each hour). Hours are emitted via `onDurationChange` as a 0–23 value as usual. `hourLimit` and `hourInterval` are honoured: rows in the **hour** column grey/snap based on the currently selected AM/PM. The AM/PM column itself is always freely toggleable so users can switch halves to reach any valid hour. The AM/PM column width can be customised via `pickerColumnWidth.amPm`. Note: `maximumHours` is currently ignored in this mode — the column always shows the full 12-hour cycle. | 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 | @@ -609,6 +610,8 @@ For deeper style customization, you can supply the following custom styles to ad | pickerLabel | Style for the picker's labels | TextStyle | | pickerAmPmContainer | Style for the picker's labels | ViewStyle | | pickerAmPmLabel | Style for the picker's labels | TextStyle | +| separateAmPmItem | Style for rows in the standalone AM/PM column when `separateAmPmPicker` is enabled. Layered on top of `pickerItem` | TextStyle | +| selectedSeparateAmPmItem | Style for the centred (selected) row in the standalone AM/PM column when `separateAmPmPicker` is enabled. Layered on top of `separateAmPmItem` | TextStyle | | pickerItemContainer | Container for each number in the picker | ViewStyle & { height?: number } | | pickerItem | Style for each number | TextStyle | | disabledPickerItem | Style for any numbers outside any set limits | TextStyle | diff --git a/examples/example-expo/App.tsx b/examples/example-expo/App.tsx index 84a3acc..39ca9d3 100644 --- a/examples/example-expo/App.tsx +++ b/examples/example-expo/App.tsx @@ -31,10 +31,13 @@ export default function App() { const [showPickerExample1, setShowPickerExample1] = useState(false); const [showPickerExample2, setShowPickerExample2] = useState(false); const [showPickerExample3, setShowPickerExample3] = useState(false); + const [showPickerSeparateAmPm, setShowPickerSeparateAmPm] = useState(false); const [alarmStringExample1, setAlarmStringExample1] = useState(null); const [alarmStringExample2, setAlarmStringExample2] = useState(null); const [alarmStringExample3, setAlarmStringExample3] = useState("00:00:00"); + const [alarmStringSeparateAmPm, setAlarmStringSeparateAmPm] = useState(null); const [hourLimitTestValue, setHourLimitTestValue] = useState(20); + const [hourLimitSeparateTestValue, setHourLimitSeparateTestValue] = useState(20); // N.B. Uncomment this to use audio (requires development build) // useEffect(() => { @@ -170,6 +173,53 @@ export default function App() { ); + const renderExampleSeparateAmPm = ( + + + {alarmStringSeparateAmPm !== null ? "Alarm set for" : "No alarm set"} + + setShowPickerSeparateAmPm(true)}> + + {alarmStringSeparateAmPm !== null ? ( + {alarmStringSeparateAmPm} + ) : null} + setShowPickerSeparateAmPm(true)}> + + {"Set Alarm 🔔"} + + + + + {"use12HourPicker + separateAmPmPicker"} + setShowPickerSeparateAmPm(false)} + onConfirm={(pickedDuration) => { + setAlarmStringSeparateAmPm(formatTime(pickedDuration)); + setShowPickerSeparateAmPm(false); + }} + padHoursWithZero={false} + pickerFeedback={pickerFeedback} + pmLabel="PM" + separateAmPmPicker + setIsVisible={setShowPickerSeparateAmPm} + styles={{ + selectedSeparateAmPmItem: { color: "#1B6EF1" }, + separateAmPmItem: { fontSize: 16, fontWeight: "600" }, + theme: "light", + }} + use12HourPicker + visible={showPickerSeparateAmPm} + /> + + ); + const renderExample3 = ( @@ -313,9 +363,48 @@ export default function App() { ); - const pageIndicesWithDarkBackground = [0, 3, 5]; + const renderHourLimitSeparateTest = ( + + + Cross-midnight hourLimit + separate AM/PM + + + {"use12HourPicker + separateAmPmPicker\nhourLimit={ min: 20, max: 5 } (8 PM – 5 AM)"} + + + { + "Hour rows grey based on current AM/PM.\nAM/PM rows grey based on current hour.\nMomentum-scroll snaps within each column." + } + + + Live reported hour value: + {formatLiveValue(hourLimitSeparateTestValue)} + + setHourLimitSeparateTestValue(d.hours)} + padHoursWithZero={false} + padWithNItems={2} + pickerFeedback={pickerFeedback} + pmLabel="PM" + separateAmPmPicker + styles={{ + pickerItem: { fontSize: 28 }, + pickerLabel: { fontSize: 22 }, + theme: "dark", + }} + use12HourPicker + /> + + ); + + const pageIndicesWithDarkBackground = [0, 4, 6, 7]; const isDarkBackground = pageIndicesWithDarkBackground.includes(currentPageIndex); - const isFinalPage = currentPageIndex === 5; + const isFinalPage = currentPageIndex === 7; const isFirstPage = currentPageIndex === 0; const renderNavigationArrows = ( @@ -384,10 +473,12 @@ export default function App() { > {renderExample1} {renderExample2} + {renderExampleSeparateAmPm} {renderExample3} {renderExample4} {renderExample5} {renderHourLimitTest} + {renderHourLimitSeparateTest} {renderNavigationArrows} @@ -419,6 +510,11 @@ const styles = StyleSheet.create({ color: "#C2C2C2", }, buttonLight: { borderColor: "#8C8C8C", color: "#8C8C8C" }, + captionLight: { + color: "#8C8C8C", + fontSize: 13, + marginTop: 24, + }, chevronPressable: { alignItems: "center", bottom: 0, @@ -468,6 +564,9 @@ const styles = StyleSheet.create({ backgroundColor: "#202020", paddingHorizontal: 20, }, + pageSeparateAmPmContainer: { + backgroundColor: "#F1F1F1", + }, root: { flex: 1, }, diff --git a/src/components/DurationScroll/DurationScroll.tsx b/src/components/DurationScroll/DurationScroll.tsx index e89775d..e1b4872 100644 --- a/src/components/DurationScroll/DurationScroll.tsx +++ b/src/components/DurationScroll/DurationScroll.tsx @@ -12,11 +12,16 @@ import { View, Text, FlatList as RNFlatList } from "react-native"; import type { ViewabilityConfigCallbackPairs, FlatListProps } from "react-native"; import { colorToRgba } from "../../utils/colorToRgba"; -import { generate12HourNumbers, generateNumbers } from "../../utils/generateNumbers"; +import { + generate12HourCycleNumbers, + generate12HourNumbers, + generateAmPmItems, + generateNumbers, +} from "../../utils/generateNumbers"; import { getAdjustedLimit } from "../../utils/getAdjustedLimit"; import { getDurationAndIndexFromScrollOffset } from "../../utils/getDurationAndIndexFromScrollOffset"; import { getInitialScrollIndex } from "../../utils/getInitialScrollIndex"; -import { isWithinLimit } from "../../utils/isWithinLimit"; +import { getNearestInRange } from "../../utils/getNearestInRange"; import PickerItem from "../PickerItem"; import type { DurationScrollProps, DurationScrollRef, ExpoAvAudioInstance } from "./types"; @@ -33,11 +38,14 @@ const DurationScroll = forwardRef((props decelerationRate = 0.88, disableInfiniteScroll = false, FlatList = RNFlatList, + getValidValue, Haptics, initialValue = 0, interval, is12HourPicker, + isAmPmPicker, isDisabled, + isItemDisabled, label, limit, LinearGradient, @@ -54,6 +62,7 @@ const DurationScroll = forwardRef((props repeatNumbersNTimes = 3, repeatNumbersNTimesNotExplicitlySet, selectedValue, + separateAmPmPicker, styles, testID, } = props; @@ -113,7 +122,27 @@ const DurationScroll = forwardRef((props ]); const numbersForFlatList = useMemo(() => { + if (isAmPmPicker) { + return generateAmPmItems({ + amLabel: amLabel ?? "am", + padWithNItems, + pmLabel: pmLabel ?? "pm", + }); + } + if (is12HourPicker) { + // When AM/PM is rendered as a separate column, the hour column shows + // 12, 1, 2, ..., 11 (no AM/PM suffix). + if (separateAmPmPicker) { + return generate12HourCycleNumbers({ + disableInfiniteScroll, + interval, + padNumbersWithZero, + padWithNItems, + repeatNTimes: safeRepeatNumbersNTimes, + }); + } + return generate12HourNumbers({ disableInfiniteScroll, interval, @@ -131,13 +160,17 @@ const DurationScroll = forwardRef((props repeatNTimes: safeRepeatNumbersNTimes, }); }, [ + amLabel, disableInfiniteScroll, is12HourPicker, + isAmPmPicker, interval, numberOfItems, padNumbersWithZero, padWithNItems, + pmLabel, safeRepeatNumbersNTimes, + separateAmPmPicker, ]); const initialScrollIndex = useMemo( @@ -230,10 +263,13 @@ const DurationScroll = forwardRef((props allowFontScaling={allowFontScaling} amLabel={amLabel} is12HourPicker={is12HourPicker} + isAmPmPicker={isAmPmPicker} + isItemDisabled={isItemDisabled} item={item} pickerAmPmPositionStyle={labelPositionStyle} pmLabel={pmLabel} selectedValue={selectedValue} + separateAmPmPicker={separateAmPmPicker} styles={styles} /> ), @@ -243,30 +279,22 @@ const DurationScroll = forwardRef((props allowFontScaling, amLabel, is12HourPicker, + isAmPmPicker, + isItemDisabled, labelPositionStyle, pmLabel, selectedValue, + separateAmPmPicker, styles, ] ); - // returns the in-range value that's closest (in scroll distance) to `value`, - // honouring wraparound limits where max < min. const getNearestInRangeValue = useCallback( - (value: number) => { - const { max, min } = adjustedLimited; - if (isWithinLimit(value, min, max)) return value; - - if (max < min) { - // wraparound: `value` lies in the gap between max and min - const distanceForwardToMin = min - value; - const distanceBackwardToMax = value - max; - return distanceForwardToMin <= distanceBackwardToMax ? min : max; - } - - return value > max ? max : min; - }, - [adjustedLimited] + (value: number) => + getValidValue + ? getValidValue(value) + : getNearestInRange(value, adjustedLimited.min, adjustedLimited.max), + [adjustedLimited, getValidValue] ); const onScroll = useCallback["onScroll"]>>( diff --git a/src/components/DurationScroll/types.ts b/src/components/DurationScroll/types.ts index 0bd2030..c63223b 100644 --- a/src/components/DurationScroll/types.ts +++ b/src/components/DurationScroll/types.ts @@ -15,11 +15,25 @@ export interface DurationScrollProps { decelerationRate?: number | "normal" | "fast"; disableInfiniteScroll?: boolean; FlatList?: any; + /** + * Optional override for the column's snap-to-valid logic. When provided, + * `onMomentumScrollEnd` runs the raw value through this function instead of + * the default `limit`-based clamp. Used by `TimerPicker` to inject cross-column + * context (e.g. AM/PM) into hour-column snapping. + */ + getValidValue?: (rawValue: number) => number; Haptics?: any; initialValue?: number; interval: number; is12HourPicker?: boolean; + isAmPmPicker?: boolean; isDisabled?: boolean; + /** + * Optional override for per-row "is this disabled?" decision. When provided, + * `PickerItem` calls this with the parsed row value instead of comparing against + * the column-local `limit`. Used by `TimerPicker` for combined-hour greying. + */ + isItemDisabled?: (value: number) => boolean; label?: string | React.ReactElement; limit?: Limit; LinearGradient?: any; @@ -36,6 +50,7 @@ export interface DurationScrollProps { repeatNumbersNTimes?: number; repeatNumbersNTimesNotExplicitlySet: boolean; selectedValue?: number; + separateAmPmPicker?: boolean; styles: ReturnType; testID?: string; } diff --git a/src/components/PickerItem/PickerItem.tsx b/src/components/PickerItem/PickerItem.tsx index 56fc9fb..0d1b21d 100644 --- a/src/components/PickerItem/PickerItem.tsx +++ b/src/components/PickerItem/PickerItem.tsx @@ -11,10 +11,13 @@ interface PickerItemProps { allowFontScaling: boolean; amLabel?: string; is12HourPicker?: boolean; + isAmPmPicker?: boolean; + isItemDisabled?: (value: number) => boolean; item: string; pickerAmPmPositionStyle?: { left: "50%"; marginLeft: number }; pmLabel?: string; selectedValue?: number; + separateAmPmPicker?: boolean; styles: ReturnType; } @@ -25,19 +28,34 @@ const PickerItem = React.memo( allowFontScaling, amLabel, is12HourPicker, + isAmPmPicker, + isItemDisabled, item, pickerAmPmPositionStyle, pmLabel, selectedValue, + separateAmPmPicker, styles, }) => { let stringItem = item; let intItem: number; let isAm: boolean | undefined; - if (!is12HourPicker) { - intItem = parseInt(item); - } else { + if (isAmPmPicker) { + // Compare to the amLabel/pmLabel passed in; padding rows are empty strings. + if (item === amLabel) { + intItem = 0; + } else if (item === pmLabel) { + intItem = 1; + } else { + intItem = NaN; + } + } else if (is12HourPicker && separateAmPmPicker) { + // Hour column in clock-face form (12, 1, 2, ..., 11). The "12" slot is hourSlot 0 + // (noon/midnight); every other display value matches its slot index. + const parsed = parseInt(item); + intItem = isNaN(parsed) ? NaN : parsed === 12 ? 0 : parsed; + } else if (is12HourPicker) { isAm = item.includes("AM"); stringItem = item.replace(/\s[AP]M/g, ""); intItem = parseInt(stringItem); @@ -48,10 +66,21 @@ const PickerItem = React.memo( } else if (isAm && intItem === 12) { intItem = 0; } + } else { + intItem = parseInt(item); } - const isSelected = intItem === selectedValue; - const isDisabled = !isWithinLimit(intItem, adjustedLimitedMin, adjustedLimitedMax); + const isSelected = !isNaN(intItem) && intItem === selectedValue; + + let isDisabled: boolean; + if (isAmPmPicker) { + // AM/PM is always freely toggleable; the hour column does all limit enforcement. + isDisabled = false; + } else if (isItemDisabled) { + isDisabled = !isNaN(intItem) && isItemDisabled(intItem); + } else { + isDisabled = !isWithinLimit(intItem, adjustedLimitedMin, adjustedLimitedMax); + } return ( @@ -61,11 +90,13 @@ const PickerItem = React.memo( styles.pickerItem, isSelected && styles.selectedPickerItem, isDisabled ? styles.disabledPickerItem : {}, + isAmPmPicker && styles.separateAmPmItem, + isAmPmPicker && isSelected && styles.selectedSeparateAmPmItem, ]} > {stringItem} - {is12HourPicker && ( + {is12HourPicker && !separateAmPmPicker && ( {isAm ? amLabel : pmLabel} diff --git a/src/components/TimerPicker/TimerPicker.tsx b/src/components/TimerPicker/TimerPicker.tsx index 43af37c..f6729ad 100644 --- a/src/components/TimerPicker/TimerPicker.tsx +++ b/src/components/TimerPicker/TimerPicker.tsx @@ -10,6 +10,9 @@ import React, { import { View } from "react-native"; import { getSafeInitialValue } from "../../utils/getSafeInitialValue"; +import { isWithinLimit } from "../../utils/isWithinLimit"; +import { combineToHour24, splitHour24 } from "../../utils/separateAmPmHour"; +import { findNearestValidHourSlot } from "../../utils/snapSeparateAmPmHour"; import DurationScroll from "../DurationScroll"; import type { DurationScrollRef } from "../DurationScroll"; import { generateStyles } from "./styles"; @@ -75,11 +78,14 @@ const TimerPicker = forwardRef((props, ref) => secondLabel, secondLimit, secondsPickerIsDisabled = false, + separateAmPmPicker = false, styles: customStyles, use12HourPicker = false, ...otherProps } = props; + const useSeparateAmPm = use12HourPicker && separateAmPmPicker; + useEffect(() => { if (otherProps.Audio) { console.warn( @@ -107,12 +113,20 @@ const TimerPicker = forwardRef((props, ref) => ); } } + if (use12HourPicker && separateAmPmPicker && maximumHours !== 23) { + console.warn( + '"maximumHours" is currently ignored when "separateAmPmPicker" is enabled. The hours column always shows the full 12-hour clock cycle (12, 1–11).' + ); + } }, [ otherProps.Audio, otherProps.Haptics, otherProps.clickSoundAsset, customStyles?.labelOffsetPercentage, customStyles?.pickerLabelGap, + maximumHours, + separateAmPmPicker, + use12HourPicker, ]); const safePadWithNItems = useMemo(() => { @@ -149,40 +163,89 @@ const TimerPicker = forwardRef((props, ref) => [customStyles] ); + const initialHourSplit = useMemo( + () => splitHour24(safeInitialValue.hours), + [safeInitialValue.hours] + ); + const [selectedDays, setSelectedDays] = useState(safeInitialValue.days); - const [selectedHours, setSelectedHours] = useState(safeInitialValue.hours); + const [selectedHours, setSelectedHours] = useState( + useSeparateAmPm ? initialHourSplit.hourSlot : safeInitialValue.hours + ); + const [selectedAmPm, setSelectedAmPm] = useState(initialHourSplit.amPm); const [selectedMinutes, setSelectedMinutes] = useState(safeInitialValue.minutes); const [selectedSeconds, setSelectedSeconds] = useState(safeInitialValue.seconds); useEffect(() => { + const hours = useSeparateAmPm ? combineToHour24(selectedHours, selectedAmPm) : selectedHours; onDurationChange?.({ days: selectedDays, - hours: selectedHours, + hours, minutes: selectedMinutes, seconds: selectedSeconds, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDays, selectedHours, selectedMinutes, selectedSeconds]); + }, [selectedDays, selectedHours, selectedAmPm, selectedMinutes, selectedSeconds]); const daysDurationScrollRef = useRef(null); const hoursDurationScrollRef = useRef(null); + const amPmDurationScrollRef = useRef(null); const minutesDurationScrollRef = useRef(null); const secondsDurationScrollRef = useRef(null); + // In separateAmPmPicker mode the hour column's snap and greying both depend on the + // currently selected AM/PM. These callbacks fold the cross-column context in; + // DurationScroll uses `getValidValue` for momentum-scroll snapping and `isItemDisabled` + // for per-row greying via PickerItem. + const getValidHourSlot = (rawHourSlot: number) => + findNearestValidHourSlot(rawHourSlot, selectedAmPm, hourLimit, hourInterval); + + const isHourSlotDisabled = (hourSlot: number) => { + if (!hourLimit || (hourLimit.min === undefined && hourLimit.max === undefined)) { + return false; + } + return !isWithinLimit( + combineToHour24(hourSlot, selectedAmPm), + hourLimit.min ?? 0, + hourLimit.max ?? 23 + ); + }; + + // In separateAmPmPicker mode the public `latestDuration.hours` must combine the cycle + // index and the AM/PM flag back into a 24-hour value. + const combinedHoursLatestDuration = useMemo<{ readonly current: number }>( + () => ({ + get current() { + const hourSlot = hoursDurationScrollRef.current?.latestDuration.current ?? 0; + const amPm = amPmDurationScrollRef.current?.latestDuration.current ?? 0; + return combineToHour24(hourSlot, amPm); + }, + }), + [] + ); + useImperativeHandle(ref, () => ({ latestDuration: { days: daysDurationScrollRef.current?.latestDuration, - hours: hoursDurationScrollRef.current?.latestDuration, + hours: useSeparateAmPm + ? combinedHoursLatestDuration + : hoursDurationScrollRef.current?.latestDuration, minutes: minutesDurationScrollRef.current?.latestDuration, seconds: secondsDurationScrollRef.current?.latestDuration, }, reset: (options) => { setSelectedDays(safeInitialValue.days); - setSelectedHours(safeInitialValue.hours); + if (useSeparateAmPm) { + setSelectedHours(initialHourSplit.hourSlot); + setSelectedAmPm(initialHourSplit.amPm); + } else { + setSelectedHours(safeInitialValue.hours); + } setSelectedMinutes(safeInitialValue.minutes); setSelectedSeconds(safeInitialValue.seconds); daysDurationScrollRef.current?.reset(options); hoursDurationScrollRef.current?.reset(options); + amPmDurationScrollRef.current?.reset(options); minutesDurationScrollRef.current?.reset(options); secondsDurationScrollRef.current?.reset(options); }, @@ -192,8 +255,16 @@ const TimerPicker = forwardRef((props, ref) => daysDurationScrollRef.current?.setValue(value.days, options); } if (value.hours !== undefined) { - setSelectedHours(value.hours); - hoursDurationScrollRef.current?.setValue(value.hours, options); + if (useSeparateAmPm) { + const split = splitHour24(value.hours); + setSelectedHours(split.hourSlot); + setSelectedAmPm(split.amPm); + hoursDurationScrollRef.current?.setValue(split.hourSlot, options); + amPmDurationScrollRef.current?.setValue(split.amPm, options); + } else { + setSelectedHours(value.hours); + hoursDurationScrollRef.current?.setValue(value.hours, options); + } } if (value.minutes !== undefined) { setSelectedMinutes(value.minutes); @@ -241,13 +312,15 @@ const TimerPicker = forwardRef((props, ref) => amLabel={amLabel} decelerationRate={decelerationRate} disableInfiniteScroll={disableInfiniteScroll} - initialValue={safeInitialValue.hours} + getValidValue={useSeparateAmPm ? getValidHourSlot : undefined} + initialValue={useSeparateAmPm ? initialHourSplit.hourSlot : safeInitialValue.hours} interval={hourInterval} is12HourPicker={use12HourPicker} isDisabled={hoursPickerIsDisabled} + isItemDisabled={useSeparateAmPm ? isHourSlotDisabled : undefined} label={hourLabel ?? (!use12HourPicker ? "h" : undefined)} - limit={hourLimit} - maximumValue={maximumHours} + limit={useSeparateAmPm ? undefined : hourLimit} + maximumValue={useSeparateAmPm ? 11 : maximumHours} onDurationChange={setSelectedHours} padNumbersWithZero={padHoursWithZero} padWithNItems={safePadWithNItems} @@ -257,6 +330,7 @@ const TimerPicker = forwardRef((props, ref) => repeatNumbersNTimes={repeatHourNumbersNTimes} repeatNumbersNTimesNotExplicitlySet={props?.repeatHourNumbersNTimes === undefined} selectedValue={selectedHours} + separateAmPmPicker={separateAmPmPicker} styles={styles} testID="duration-scroll-hour" {...otherProps} @@ -314,6 +388,31 @@ const TimerPicker = forwardRef((props, ref) => {...otherProps} /> ) : null} + {useSeparateAmPm ? ( + + ) : null} ); }); diff --git a/src/components/TimerPicker/styles.ts b/src/components/TimerPicker/styles.ts index cf4da5f..13b5dfb 100644 --- a/src/components/TimerPicker/styles.ts +++ b/src/components/TimerPicker/styles.ts @@ -1,7 +1,7 @@ import { StyleSheet } from "react-native"; import type { TextStyle, ViewStyle } from "react-native"; -export type PickerColumn = "days" | "hours" | "minutes" | "seconds"; +export type PickerColumn = "amPm" | "days" | "hours" | "minutes" | "seconds"; export type PerColumnValue = number | Partial>; @@ -25,6 +25,8 @@ export interface CustomTimerPickerStyles { pickerLabelContainer?: ViewStyle; pickerLabelGap?: PerColumnValue; selectedPickerItem?: TextStyle; + selectedSeparateAmPmItem?: TextStyle; + separateAmPmItem?: TextStyle; text?: TextStyle; theme?: "light" | "dark"; } @@ -167,5 +169,11 @@ export const generateStyles = (customStyles: CustomTimerPickerStyles | undefined ...customStyles?.pickerItem, ...customStyles?.selectedPickerItem, }, + selectedSeparateAmPmItem: { + ...customStyles?.selectedSeparateAmPmItem, + }, + separateAmPmItem: { + ...customStyles?.separateAmPmItem, + }, }); }; diff --git a/src/components/TimerPicker/types.ts b/src/components/TimerPicker/types.ts index 8144ada..0962f57 100644 --- a/src/components/TimerPicker/types.ts +++ b/src/components/TimerPicker/types.ts @@ -1,17 +1,18 @@ import type React from "react"; -import { type RefObject } from "react"; import type { View } from "react-native"; import type { LinearGradientProps, SoundAsset, Limit } from "../DurationScroll/types"; import type { CustomTimerPickerStyles } from "./styles"; +export type LatestDurationRef = { readonly current: number }; + export interface TimerPickerRef { latestDuration: { - days: RefObject | undefined; - hours: RefObject | undefined; - minutes: RefObject | undefined; - seconds: RefObject | undefined; + days: LatestDurationRef | undefined; + hours: LatestDurationRef | undefined; + minutes: LatestDurationRef | undefined; + seconds: LatestDurationRef | undefined; }; reset: (options?: { animated?: boolean }) => void; setValue: ( @@ -94,6 +95,11 @@ export interface TimerPickerProps { secondLabel?: string | React.ReactElement; secondLimit?: Limit; secondsPickerIsDisabled?: boolean; + /** + * Render AM/PM as a separate scrollable column instead of appending it to each hour. + * Only takes effect when `use12HourPicker` is true. Defaults to `false`. + */ + separateAmPmPicker?: boolean; styles?: CustomTimerPickerStyles; use12HourPicker?: boolean; } diff --git a/src/components/TimerPickerModal/TimerPickerModal.tsx b/src/components/TimerPickerModal/TimerPickerModal.tsx index 022568e..5fd5ca9 100644 --- a/src/components/TimerPickerModal/TimerPickerModal.tsx +++ b/src/components/TimerPickerModal/TimerPickerModal.tsx @@ -60,12 +60,16 @@ const TimerPickerModal = forwardRef( } = props; const pickerColumnWidth = customStyles?.pickerColumnWidth; + const hasSeparateAmPmColumn = Boolean( + otherProps.use12HourPicker && otherProps.separateAmPmPicker && !hideHours + ); const totalColumnWidth = (!hideDays ? resolveColumnWidth(pickerColumnWidth, "days") : 0) + (!hideHours ? resolveColumnWidth(pickerColumnWidth, "hours") : 0) + (!hideMinutes ? resolveColumnWidth(pickerColumnWidth, "minutes") : 0) + - (!hideSeconds ? resolveColumnWidth(pickerColumnWidth, "seconds") : 0); + (!hideSeconds ? resolveColumnWidth(pickerColumnWidth, "seconds") : 0) + + (hasSeparateAmPmColumn ? resolveColumnWidth(pickerColumnWidth, "amPm") : 0); const styles = generateStyles(customStyles, { hasModalTitle: Boolean(modalTitle), diff --git a/src/components/TimerPickerModal/types.ts b/src/components/TimerPickerModal/types.ts index 1413213..fe7ab48 100644 --- a/src/components/TimerPickerModal/types.ts +++ b/src/components/TimerPickerModal/types.ts @@ -1,18 +1,18 @@ import type React from "react"; -import { type RefObject, type ReactElement } from "react"; +import { type ReactElement } from "react"; import type { View, TouchableOpacity, Text } from "react-native"; import type Modal from "../Modal"; -import type { TimerPickerProps } from "../TimerPicker/types"; +import type { LatestDurationRef, TimerPickerProps } from "../TimerPicker/types"; import type { CustomTimerPickerModalStyles } from "./styles"; export interface TimerPickerModalRef { latestDuration: { - days: RefObject | undefined; - hours: RefObject | undefined; - minutes: RefObject | undefined; - seconds: RefObject | undefined; + days: LatestDurationRef | undefined; + hours: LatestDurationRef | undefined; + minutes: LatestDurationRef | undefined; + seconds: LatestDurationRef | undefined; }; reset: (options?: { animated?: boolean }) => void; setValue: ( diff --git a/src/tests/DurationScroll.test.tsx b/src/tests/DurationScroll.test.tsx index 664fa5f..0e2c076 100644 --- a/src/tests/DurationScroll.test.tsx +++ b/src/tests/DurationScroll.test.tsx @@ -18,6 +18,8 @@ describe("DurationScroll", () => { pickerItemContainer: {}, pickerLabel: {}, pickerLabelContainer: {}, + selectedSeparateAmPmItem: {}, + separateAmPmItem: {}, } as ReturnType; it("renders without crashing", () => { diff --git a/src/tests/PickerItem.test.tsx b/src/tests/PickerItem.test.tsx index 44e0e9c..db51a18 100644 --- a/src/tests/PickerItem.test.tsx +++ b/src/tests/PickerItem.test.tsx @@ -18,25 +18,35 @@ const styles = { pickerLabel: {}, pickerLabelContainer: {}, selectedPickerItem: { fontWeight: "bold" as const }, + selectedSeparateAmPmItem: {}, + separateAmPmItem: {}, } as ReturnType; const renderItem = (props: { adjustedLimitedMax: number; adjustedLimitedMin: number; + amLabel?: string; is12HourPicker?: boolean; + isAmPmPicker?: boolean; + isItemDisabled?: (value: number) => boolean; item: string; + pmLabel?: string; selectedValue?: number; + separateAmPmPicker?: boolean; }) => render( ); @@ -174,4 +184,242 @@ describe("PickerItem", () => { expect(isDisabledStyle(getByText("20"))).toBe(false); }); }); + + describe("12-hour cycle picker (separate AM/PM)", () => { + it("marks '12' as selected when selectedValue is 0", () => { + const { getByText } = renderItem({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + is12HourPicker: true, + item: "12", + selectedValue: 0, + separateAmPmPicker: true, + }); + expect(isSelectedStyle(getByText("12"))).toBe(true); + }); + + it("does not mark '12' as selected when selectedValue is 12", () => { + const { getByText } = renderItem({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + is12HourPicker: true, + item: "12", + selectedValue: 12, + separateAmPmPicker: true, + }); + expect(isSelectedStyle(getByText("12"))).toBe(false); + }); + + it("marks '03' as selected when selectedValue is 3", () => { + const { getByText } = renderItem({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + is12HourPicker: true, + item: "03", + selectedValue: 3, + separateAmPmPicker: true, + }); + expect(isSelectedStyle(getByText("03"))).toBe(true); + }); + + it("falls back to column-local limit when no isItemDisabled callback is provided", () => { + // In actual usage, the cycle hour column receives limit={undefined}, so + // adjustedLimited covers 0..11 and no row is greyed by default. + const { getByText } = renderItem({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + is12HourPicker: true, + item: "11", + separateAmPmPicker: true, + }); + expect(isDisabledStyle(getByText("11"))).toBe(false); + }); + + describe("greying via isItemDisabled callback", () => { + it("calls isItemDisabled with the parsed hourSlot ('12' → 0)", () => { + const isItemDisabled = jest.fn((value: number) => value === 0); + const { getByText } = renderItem({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + is12HourPicker: true, + isItemDisabled, + item: "12", + separateAmPmPicker: true, + }); + expect(isItemDisabled).toHaveBeenCalledWith(0); + expect(isDisabledStyle(getByText("12"))).toBe(true); + }); + + it("calls isItemDisabled with the parsed hourSlot ('05' → 5)", () => { + const isItemDisabled = jest.fn(() => false); + const { getByText } = renderItem({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + is12HourPicker: true, + isItemDisabled, + item: "05", + separateAmPmPicker: true, + }); + expect(isItemDisabled).toHaveBeenCalledWith(5); + expect(isDisabledStyle(getByText("05"))).toBe(false); + }); + + it("greys when callback returns true, not when false", () => { + const { getByText: a } = renderItem({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + is12HourPicker: true, + isItemDisabled: () => true, + item: "07", + separateAmPmPicker: true, + }); + expect(isDisabledStyle(a("07"))).toBe(true); + + const { getByText: b } = renderItem({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + is12HourPicker: true, + isItemDisabled: () => false, + item: "07", + separateAmPmPicker: true, + }); + expect(isDisabledStyle(b("07"))).toBe(false); + }); + }); + }); + + describe("AM/PM picker", () => { + it("marks AM as selected when selectedValue is 0", () => { + const { getByText } = renderItem({ + adjustedLimitedMax: 1, + adjustedLimitedMin: 0, + isAmPmPicker: true, + item: "AM", + selectedValue: 0, + }); + expect(isSelectedStyle(getByText("AM"))).toBe(true); + }); + + it("marks PM as selected when selectedValue is 1", () => { + const { getByText } = renderItem({ + adjustedLimitedMax: 1, + adjustedLimitedMin: 0, + isAmPmPicker: true, + item: "PM", + selectedValue: 1, + }); + expect(isSelectedStyle(getByText("PM"))).toBe(true); + }); + + it("does not mark PM as selected when selectedValue is 0", () => { + const { getByText } = renderItem({ + adjustedLimitedMax: 1, + adjustedLimitedMin: 0, + isAmPmPicker: true, + item: "PM", + selectedValue: 0, + }); + expect(isSelectedStyle(getByText("PM"))).toBe(false); + }); + + it("respects custom amLabel/pmLabel for matching", () => { + const { getByText } = renderItem({ + adjustedLimitedMax: 1, + adjustedLimitedMin: 0, + amLabel: "오전", + isAmPmPicker: true, + item: "오전", + pmLabel: "오후", + selectedValue: 0, + }); + expect(isSelectedStyle(getByText("오전"))).toBe(true); + }); + + describe("custom AM/PM styling", () => { + const customStyles = { + ...styles, + selectedSeparateAmPmItem: { color: "tomato" } as const, + separateAmPmItem: { fontSize: 14 } as const, + } as ReturnType; + + const renderWithCustom = (props: Parameters[0]) => + render( + + ); + + it("applies separateAmPmItem to AM/PM rows", () => { + const { getByText } = renderWithCustom({ + adjustedLimitedMax: 1, + adjustedLimitedMin: 0, + isAmPmPicker: true, + item: "AM", + }); + const flat = (getByText("AM").props.style as Array>).flat(); + expect(flat.some((s) => s && s.fontSize === 14)).toBe(true); + }); + + it("applies selectedSeparateAmPmItem only to the selected AM/PM row", () => { + const { getByText: getSelected } = renderWithCustom({ + adjustedLimitedMax: 1, + adjustedLimitedMin: 0, + isAmPmPicker: true, + item: "AM", + selectedValue: 0, + }); + const selectedFlat = ( + getSelected("AM").props.style as Array> + ).flat(); + expect(selectedFlat.some((s) => s && s.color === "tomato")).toBe(true); + + const { getByText: getUnselected } = renderWithCustom({ + adjustedLimitedMax: 1, + adjustedLimitedMin: 0, + isAmPmPicker: true, + item: "PM", + selectedValue: 0, + }); + const unselectedFlat = ( + getUnselected("PM").props.style as Array> + ).flat(); + expect(unselectedFlat.some((s) => s && s.color === "tomato")).toBe(false); + }); + + it("does not apply separateAmPmItem to non-AM/PM rows", () => { + const { getByText } = renderWithCustom({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + item: "05", + }); + const flat = (getByText("05").props.style as Array>).flat(); + expect(flat.some((s) => s && s.fontSize === 14)).toBe(false); + }); + }); + + describe("greying is disabled (AM/PM is always freely toggleable)", () => { + // The AM/PM column is intentionally limit-free so the user can always toggle + // halves to reach any valid hour. The hour column does the limit enforcement. + // Even with an isItemDisabled callback that always returns true, AM/PM rows + // never grey. + it.each([["AM"], ["PM"]])("row %s is never greyed", (row) => { + const { getByText } = renderItem({ + adjustedLimitedMax: 1, + adjustedLimitedMin: 0, + isAmPmPicker: true, + isItemDisabled: () => true, + item: row, + }); + expect(isDisabledStyle(getByText(row))).toBe(false); + }); + }); + }); }); diff --git a/src/tests/TimerPicker.test.tsx b/src/tests/TimerPicker.test.tsx index 4b1df50..62de259 100644 --- a/src/tests/TimerPicker.test.tsx +++ b/src/tests/TimerPicker.test.tsx @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import React from "react"; -import { render } from "@testing-library/react-native"; +import { act, render, within } from "@testing-library/react-native"; import { FlatList } from "react-native"; import TimerPicker from "../components/TimerPicker"; +import type { TimerPickerRef } from "../components/TimerPicker"; describe("TimerPicker", () => { it("renders without crashing", () => { @@ -106,4 +107,197 @@ describe("TimerPicker", () => { expect(queryByTestId("duration-scroll-minute")).toBeNull(); expect(queryByTestId("duration-scroll-second")).toBeNull(); }); + + describe("separateAmPmPicker", () => { + it("does not render an AM/PM column when separateAmPmPicker is false", () => { + const { queryByTestId } = render(); + expect(queryByTestId("duration-scroll-am-pm")).toBeNull(); + }); + + it("does not render an AM/PM column when use12HourPicker is false", () => { + const { queryByTestId } = render(); + expect(queryByTestId("duration-scroll-am-pm")).toBeNull(); + }); + + it("renders the AM/PM column when both flags are set", () => { + const { getByTestId } = render(); + expect(getByTestId("duration-scroll-am-pm")).toBeDefined(); + }); + + it("emits the initial 24-hour value through onDurationChange", () => { + const onDurationChange = jest.fn(); + render( + + ); + expect(onDurationChange).toHaveBeenCalledWith({ + days: 0, + hours: 17, + minutes: 0, + seconds: 0, + }); + }); + + it("renders 12-hour cycle hour items (no AM/PM suffix)", () => { + const { getByText, queryByText } = render( + + ); + // "12" appears as the noon/midnight slot + expect(getByText("12")).toBeDefined(); + // No suffixed strings like "12 AM" or "01 PM" + expect(queryByText("12 AM")).toBeNull(); + expect(queryByText("01 PM")).toBeNull(); + }); + + it("renders AM and PM rows in the AM/PM column", () => { + const { getAllByText } = render( + + ); + // Defaults are "am"/"pm"; we override here so the labels match exactly. + expect(getAllByText("AM").length).toBeGreaterThan(0); + expect(getAllByText("PM").length).toBeGreaterThan(0); + }); + + it("greys hour rows whose 24h value falls outside hourLimit (given current AM/PM)", () => { + // hourLimit { 9, 17 } with currentAmPm=AM (initial 9 AM = 9): + // - hour row "08" represents 8 AM = 8, out of range → greyed + // - hour row "09" represents 9 AM = 9, in range → not greyed + // - hour row "11" represents 11 AM = 11, in range → not greyed + const { getByTestId } = render( + + ); + const hoursColumn = within(getByTestId("duration-scroll-hour")); + const isGreyed = (el: { props: { style?: unknown } }) => { + const style = el.props.style; + const arr = Array.isArray(style) ? style : [style]; + return arr.some((s) => s && (s as { opacity?: number }).opacity === 0.2); + }; + + expect(hoursColumn.getAllByText("08").every(isGreyed)).toBe(true); + expect(hoursColumn.getAllByText("09").every((el) => !isGreyed(el))).toBe(true); + expect(hoursColumn.getAllByText("11").every((el) => !isGreyed(el))).toBe(true); + }); + + it("preserves the original use12HourPicker behaviour when separateAmPmPicker is false", () => { + const onDurationChange = jest.fn(); + render( + + ); + expect(onDurationChange).toHaveBeenCalledWith({ + days: 0, + hours: 17, + minutes: 0, + seconds: 0, + }); + }); + + describe("initial-value round-trip", () => { + it.each([ + [0, "12 AM"], + [1, "1 AM"], + [11, "11 AM"], + [12, "12 PM"], + [13, "1 PM"], + [17, "5 PM"], + [23, "11 PM"], + ])("emits hours=%i (%s) through onDurationChange", (hours) => { + const onDurationChange = jest.fn(); + render( + + ); + expect(onDurationChange).toHaveBeenLastCalledWith(expect.objectContaining({ hours })); + }); + }); + + describe("setValue ref API", () => { + it.each([[0], [1], [11], [12], [13], [17], [23]])( + "setValue({ hours: %i }) emits hours=%i", + (hours) => { + const onDurationChange = jest.fn(); + const ref = React.createRef(); + // Pick an initial that differs from every test case to guarantee a state change. + render( + + ); + onDurationChange.mockClear(); + act(() => { + ref.current?.setValue({ hours }); + }); + expect(onDurationChange).toHaveBeenLastCalledWith(expect.objectContaining({ hours })); + } + ); + }); + + describe("ignored prop warnings", () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("warns when maximumHours is set with separateAmPmPicker", () => { + render(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('"maximumHours" is currently ignored') + ); + }); + + it("does not warn when maximumHours is the default", () => { + render(); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('"maximumHours" is currently ignored') + ); + }); + + it("does not warn about hourLimit anymore (it now works in separate mode)", () => { + render(); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('"hourLimit" is currently ignored') + ); + }); + + it("does not warn when separateAmPmPicker is off", () => { + render(); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('"maximumHours" is currently ignored') + ); + }); + }); + }); }); diff --git a/src/tests/generateNumbers.test.ts b/src/tests/generateNumbers.test.ts index ed39add..5aec12c 100644 --- a/src/tests/generateNumbers.test.ts +++ b/src/tests/generateNumbers.test.ts @@ -1,4 +1,9 @@ -import { generateNumbers, generate12HourNumbers } from "../utils/generateNumbers"; +import { + generateNumbers, + generate12HourNumbers, + generate12HourCycleNumbers, + generateAmPmItems, +} from "../utils/generateNumbers"; describe("generateNumbers", () => { describe("basic functionality", () => { @@ -375,3 +380,94 @@ describe("generate12HourNumbers", () => { }); }); }); + +describe("generate12HourCycleNumbers", () => { + it("generates 12 items in clock order with index 0 = 12", () => { + const result = generate12HourCycleNumbers({ + disableInfiniteScroll: true, + interval: 1, + padNumbersWithZero: true, + padWithNItems: 0, + repeatNTimes: 1, + }); + expect(result).toHaveLength(12); + expect(result[0]).toBe("12"); + expect(result[1]).toBe("01"); + expect(result[11]).toBe("11"); + }); + + it("respects 2-hour interval (12, 02, 04, 06, 08, 10)", () => { + const result = generate12HourCycleNumbers({ + disableInfiniteScroll: true, + interval: 2, + padNumbersWithZero: true, + padWithNItems: 0, + repeatNTimes: 1, + }); + expect(result).toEqual(["12", "02", "04", "06", "08", "10"]); + }); + + it("pads with figure space when padNumbersWithZero is false", () => { + const result = generate12HourCycleNumbers({ + disableInfiniteScroll: true, + interval: 1, + padWithNItems: 0, + repeatNTimes: 1, + }); + expect(result[0]).toBe("12"); + expect(result[1]).toBe(" 1"); + expect(result[9]).toBe(" 9"); + expect(result[10]).toBe("10"); + }); + + it("adds padding rows when infinite scroll is disabled", () => { + const result = generate12HourCycleNumbers({ + disableInfiniteScroll: true, + interval: 6, + padNumbersWithZero: true, + padWithNItems: 2, + repeatNTimes: 1, + }); + expect(result).toEqual(["", "", "12", "06", "", ""]); + }); + + it("repeats the cycle when repeatNTimes is 2 with infinite scroll", () => { + const result = generate12HourCycleNumbers({ + disableInfiniteScroll: false, + interval: 6, + padNumbersWithZero: true, + padWithNItems: 0, + repeatNTimes: 2, + }); + expect(result).toEqual(["12", "06", "12", "06"]); + }); +}); + +describe("generateAmPmItems", () => { + it("returns am and pm with no padding", () => { + expect(generateAmPmItems({ amLabel: "AM", padWithNItems: 0, pmLabel: "PM" })).toEqual([ + "AM", + "PM", + ]); + }); + + it("pads symmetrically when padWithNItems is set", () => { + expect(generateAmPmItems({ amLabel: "AM", padWithNItems: 2, pmLabel: "PM" })).toEqual([ + "", + "", + "AM", + "PM", + "", + "", + ]); + }); + + it("uses custom labels", () => { + expect(generateAmPmItems({ amLabel: "오전", padWithNItems: 1, pmLabel: "오후" })).toEqual([ + "", + "오전", + "오후", + "", + ]); + }); +}); diff --git a/src/tests/snapSeparateAmPmHour.test.ts b/src/tests/snapSeparateAmPmHour.test.ts new file mode 100644 index 0000000..0826f0d --- /dev/null +++ b/src/tests/snapSeparateAmPmHour.test.ts @@ -0,0 +1,116 @@ +import { findNearestValidHourSlot } from "../utils/snapSeparateAmPmHour"; + +describe("findNearestValidHourSlot", () => { + describe("no limit", () => { + it.each([0, 5, 11])("returns the input hourSlot %i unchanged", (hourSlot) => { + expect(findNearestValidHourSlot(hourSlot, 0, undefined)).toBe(hourSlot); + expect(findNearestValidHourSlot(hourSlot, 1, undefined)).toBe(hourSlot); + }); + + it("treats an empty Limit object as no limit", () => { + expect(findNearestValidHourSlot(7, 0, {})).toBe(7); + }); + }); + + describe("normal limit { min: 9, max: 17 }", () => { + const limit = { max: 17, min: 9 }; + + it.each([ + [0, 9], // 12 AM (=0) invalid → snap to 9 AM + [1, 9], + [8, 9], + [9, 9], + [10, 10], + [11, 11], + ])("AM hourSlot %i snaps to %i", (input, expected) => { + expect(findNearestValidHourSlot(input, 0, limit)).toBe(expected); + }); + + it.each([ + [0, 0], // 12 PM (=12) is valid + [1, 1], // 1 PM = 13, valid + [5, 5], // 5 PM = 17, valid + [6, 5], // 6 PM = 18, invalid → nearest valid PM hourSlot is 5 + [11, 5], + ])("PM hourSlot %i snaps to %i", (input, expected) => { + expect(findNearestValidHourSlot(input, 1, limit)).toBe(expected); + }); + }); + + describe("wraparound limit { min: 20, max: 5 } (8 PM – 5 AM)", () => { + const limit = { max: 5, min: 20 }; + + it.each([ + [0, 0], // 12 AM = 0, valid + [5, 5], // 5 AM = 5, valid + [6, 5], // 6 AM = 6, invalid → nearest valid AM is 5 + [10, 5], + [11, 5], + ])("AM hourSlot %i snaps to %i", (input, expected) => { + expect(findNearestValidHourSlot(input, 0, limit)).toBe(expected); + }); + + it.each([ + [0, 8], // 12 PM = 12, invalid → nearest valid PM is hourSlot 8 (= 20) + [7, 8], + [8, 8], // 8 PM = 20, valid + [11, 11], // 11 PM = 23, valid + ])("PM hourSlot %i snaps to %i", (input, expected) => { + expect(findNearestValidHourSlot(input, 1, limit)).toBe(expected); + }); + }); + + describe("limit excludes one full half", () => { + it("returns input unchanged when no AM hourSlot is valid (limit forces PM)", () => { + // limit { min: 13, max: 17 } means only PM hours 13-17 are valid + // No AM hourSlot (which gives 0 or 1-11) can land in [13, 17]. + expect(findNearestValidHourSlot(5, 0, { max: 17, min: 13 })).toBe(5); + }); + + it("returns input unchanged when no PM hourSlot is valid (limit forces AM)", () => { + // limit { min: 1, max: 5 } means only AM hours 1-5 are valid; no PM combo works. + expect(findNearestValidHourSlot(7, 1, { max: 5, min: 1 })).toBe(7); + }); + }); + + it("respects limit with only min set (max defaults to 23)", () => { + expect(findNearestValidHourSlot(0, 0, { min: 12 })).toBe(0); // No AM works; falls through to input + expect(findNearestValidHourSlot(0, 1, { min: 12 })).toBe(0); // 12 PM = 12, valid + }); + + it("respects limit with only max set (min defaults to 0)", () => { + expect(findNearestValidHourSlot(0, 0, { max: 5 })).toBe(0); // 12 AM = 0, valid + expect(findNearestValidHourSlot(11, 0, { max: 5 })).toBe(5); + }); + + describe("with hourInterval", () => { + // generate12HourCycleNumbers iterates `i = 0; i < 12; i += interval`. The snap + // helper must mirror that so it never returns an hourSlot not present in the column. + it.each([ + // interval=2 → hour slots [0, 2, 4, 6, 8, 10] + [2, 0, 0, { max: 17, min: 9 }, 10], // raw 0 AM invalid; nearest valid AM hourSlot is 10 + [2, 5, 0, { max: 17, min: 9 }, 10], // raw 5 (not in column) invalid; nearest is 10 + [2, 4, 1, { max: 17, min: 9 }, 4], // raw 4 PM = 16, valid → unchanged + // interval=3 → hour slots [0, 3, 6, 9] + [3, 0, 0, { max: 17, min: 9 }, 9], // 12 AM invalid; only 9 AM in column is in range + [3, 6, 1, { max: 17, min: 9 }, 3], // 6 PM=18 invalid; nearest valid PM hourSlot is 3 (3 PM=15) + // interval=4 → hour slots [0, 4, 8] + [4, 0, 0, { max: 17, min: 9 }, 0], // no AM hourSlot in column is valid → no snap (returns input) + [4, 0, 1, { max: 17, min: 9 }, 0], // 12 PM=12 IS in [9,17] → unchanged + [4, 0, 1, { max: 17, min: 13 }, 4], // 12 PM=12 invalid in [13,17]; nearest PM is 4 (4 PM=16) + ])("interval=%i, raw=%i, amPm=%i, limit=%p → %i", (interval, raw, amPm, limit, expected) => { + expect(findNearestValidHourSlot(raw, amPm, limit, interval)).toBe(expected); + }); + + it("never returns an hourSlot outside the rendered set when called with a valid raw", () => { + // interval=2: rendered hour slots are {0,2,4,6,8,10}. The raw hourSlot in real + // usage always comes from getDurationAndIndexFromScrollOffset (= multiple of interval). + const limit = { max: 17, min: 9 }; + const renderedSet = [0, 2, 4, 6, 8, 10]; + for (const raw of renderedSet) { + const result = findNearestValidHourSlot(raw, 0, limit, 2); + expect(renderedSet.includes(result)).toBe(true); + } + }); + }); +}); diff --git a/src/utils/generateNumbers.ts b/src/utils/generateNumbers.ts index d9eff4b..c99b0c6 100644 --- a/src/utils/generateNumbers.ts +++ b/src/utils/generateNumbers.ts @@ -128,3 +128,64 @@ export const generate12HourNumbers = (options: { return numbers; }; + +/** + * Generates an array of formatted hour strings for the 12-hour clock cycle (no AM/PM suffix), + * intended for the hours column when the AM/PM column is rendered separately. + * + * Index 0 always represents the noon/midnight slot and displays as "12"; subsequent indices + * follow the supplied interval starting from 1. Internal value 0 → "12", value k → display k. + * + * @example + * // Generate the full 12-hour cycle padded with zeros + * generate12HourCycleNumbers({ + * interval: 1, + * padWithNItems: 0, + * padNumbersWithZero: true, + * repeatNTimes: 1, + * disableInfiniteScroll: true, + * }) + * // Returns: ['12', '01', '02', ..., '11'] + */ +export const generate12HourCycleNumbers = (options: { + disableInfiniteScroll?: boolean; + interval: number; + padNumbersWithZero?: boolean; + padWithNItems: number; + repeatNTimes: number; +}) => { + let numbers: string[] = []; + + for (let i = 0; i < 12; i += options.interval) { + const hour = i === 0 ? 12 : i; + numbers.push(padNumber(hour, { padWithZero: options.padNumbersWithZero })); + } + + if (options.repeatNTimes > 1) { + numbers = Array(options.repeatNTimes).fill(numbers).flat(); + } + + if (options.disableInfiniteScroll || options.repeatNTimes === 1) { + numbers.push(...Array(options.padWithNItems).fill("")); + numbers.unshift(...Array(options.padWithNItems).fill("")); + } + + return numbers; +}; + +/** + * Generates the items shown in the standalone AM/PM picker column. The column is non-looping + * and always padded so that AM and PM line up with the centre row. + * + * @example + * generateAmPmItems({ amLabel: 'AM', pmLabel: 'PM', padWithNItems: 1 }) + * // Returns: ['', 'AM', 'PM', ''] + */ +export const generateAmPmItems = (options: { + amLabel: string; + padWithNItems: number; + pmLabel: string; +}) => { + const padding: string[] = Array(options.padWithNItems).fill(""); + return [...padding, options.amLabel, options.pmLabel, ...padding]; +}; diff --git a/src/utils/getNearestInRange.ts b/src/utils/getNearestInRange.ts new file mode 100644 index 0000000..7314f50 --- /dev/null +++ b/src/utils/getNearestInRange.ts @@ -0,0 +1,19 @@ +import { isWithinLimit } from "./isWithinLimit"; + +/** + * Returns the in-range value closest (in scroll distance) to `value`, honouring + * wraparound limits where `max < min`. When `value` is already in range it is + * returned unchanged. + */ +export const getNearestInRange = (value: number, min: number, max: number): number => { + if (isWithinLimit(value, min, max)) return value; + + if (max < min) { + // wraparound: value lies in the gap between max and min + const distanceForwardToMin = min - value; + const distanceBackwardToMax = value - max; + return distanceForwardToMin <= distanceBackwardToMax ? min : max; + } + + return value > max ? max : min; +}; diff --git a/src/utils/separateAmPmHour.ts b/src/utils/separateAmPmHour.ts new file mode 100644 index 0000000..2508185 --- /dev/null +++ b/src/utils/separateAmPmHour.ts @@ -0,0 +1,18 @@ +/** + * Helpers for the `separateAmPmPicker` representation. The hour column tracks an + * `hourSlot` index in [0, 11] where slot 0 is the noon/midnight "12" position and + * slots 1–11 display as themselves. The AM/PM column tracks 0 (AM) or 1 (PM). + * The public API always exposes 24-hour values. + */ + +export const splitHour24 = (hour24: number): { amPm: 0 | 1; hourSlot: number } => { + if (hour24 === 0) return { amPm: 0, hourSlot: 0 }; + if (hour24 === 12) return { amPm: 1, hourSlot: 0 }; + if (hour24 < 12) return { amPm: 0, hourSlot: hour24 }; + return { amPm: 1, hourSlot: hour24 - 12 }; +}; + +export const combineToHour24 = (hourSlot: number, amPm: number): number => { + if (hourSlot === 0) return amPm === 1 ? 12 : 0; + return hourSlot + (amPm === 1 ? 12 : 0); +}; diff --git a/src/utils/snapSeparateAmPmHour.ts b/src/utils/snapSeparateAmPmHour.ts new file mode 100644 index 0000000..aa9831d --- /dev/null +++ b/src/utils/snapSeparateAmPmHour.ts @@ -0,0 +1,45 @@ +import type { Limit } from "../components/DurationScroll/types"; +import { isWithinLimit } from "./isWithinLimit"; +import { combineToHour24 } from "./separateAmPmHour"; + +const resolveLimit = (limit: Limit | undefined): { max: number; min: number } | null => { + if (!limit || (limit.min === undefined && limit.max === undefined)) return null; + return { + max: limit.max ?? 23, + min: limit.min ?? 0, + }; +}; + +/** + * Returns the hour slot in the rendered 12-hour clock-face column nearest to + * `rawHourSlot` such that `(hourSlot, currentAmPm)` is within `hourLimit`. Iterates + * in `interval` steps to mirror the values actually rendered by + * `generate12HourCycleNumbers`. If no value in the column satisfies the limit, + * `rawHourSlot` is returned unchanged. + */ +export const findNearestValidHourSlot = ( + rawHourSlot: number, + currentAmPm: number, + hourLimit: Limit | undefined, + interval = 1 +): number => { + const resolved = resolveLimit(hourLimit); + if (!resolved) return rawHourSlot; + + if (isWithinLimit(combineToHour24(rawHourSlot, currentAmPm), resolved.min, resolved.max)) { + return rawHourSlot; + } + + let best: number | null = null; + let bestDistance = Infinity; + for (let i = 0; i < 12; i += interval) { + if (!isWithinLimit(combineToHour24(i, currentAmPm), resolved.min, resolved.max)) continue; + const distance = Math.abs(i - rawHourSlot); + if (distance < bestDistance) { + bestDistance = distance; + best = i; + } + } + + return best ?? rawHourSlot; +};