From 2a02f5f8dd8be1a065f5ab032d67a57788492477 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 4 May 2026 22:54:50 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Add=20separateAmPmPicker=20prop?= =?UTF-8?q?=20with=20hourLimit=20support=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When use12HourPicker is enabled, separateAmPmPicker renders AM/PM as a dedicated scrollable column after seconds instead of appending it to each hour. The hours column shows 12, 1, 2, …, 11 in clock order. Public API (onDurationChange, latestDuration, setValue) keeps using 0–23 hours. hourLimit is honoured in this mode: the hour column's rows grey based on the currently selected AM/PM, and momentum-scroll snaps to the nearest valid hour in that half. The AM/PM column is intentionally limit-free so users can always toggle halves to reach any valid hour. Wraparound limits (max < min) are handled the same as in the existing 12-hour mode. The TimerPickerModal width calculation now counts the AM/PM column when present, so default styles work without manual pickerColumnWidth tweaks. maximumHours remains ignored in separate mode (warns at runtime). --- README.md | 1 + examples/example-expo/App.tsx | 99 +++++++- .../DurationScroll/DurationScroll.tsx | 61 +++-- src/components/DurationScroll/types.ts | 4 + src/components/PickerItem/PickerItem.tsx | 56 ++++- src/components/TimerPicker/TimerPicker.tsx | 121 +++++++++- src/components/TimerPicker/types.ts | 5 + .../TimerPickerModal/TimerPickerModal.tsx | 6 +- src/tests/PickerItem.test.tsx | 213 +++++++++++++++++- src/tests/TimerPicker.test.tsx | 196 +++++++++++++++- src/tests/generateNumbers.test.ts | 98 +++++++- src/tests/snapSeparateAmPmHour.test.ts | 85 +++++++ src/utils/generateNumbers.ts | 61 +++++ src/utils/getNearestInRange.ts | 19 ++ src/utils/separateAmPmHour.ts | 17 ++ src/utils/snapSeparateAmPmHour.ts | 42 ++++ 16 files changed, 1043 insertions(+), 41 deletions(-) create mode 100644 src/tests/snapSeparateAmPmHour.test.ts create mode 100644 src/utils/getNearestInRange.ts create mode 100644 src/utils/separateAmPmHour.ts create mode 100644 src/utils/snapSeparateAmPmHour.ts diff --git a/README.md b/README.md index d46a364..f672984 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` is 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. 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 | diff --git a/examples/example-expo/App.tsx b/examples/example-expo/App.tsx index 84a3acc..798b7fd 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,49 @@ 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={{ theme: "light" }} + use12HourPicker + visible={showPickerSeparateAmPm} + /> + + ); + const renderExample3 = ( @@ -313,9 +359,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 +469,12 @@ export default function App() { > {renderExample1} {renderExample2} + {renderExampleSeparateAmPm} {renderExample3} {renderExample4} {renderExample5} {renderHourLimitTest} + {renderHourLimitSeparateTest} {renderNavigationArrows} @@ -419,6 +506,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 +560,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..655d182 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"; @@ -30,6 +35,8 @@ const DurationScroll = forwardRef((props amLabel, Audio, clickSoundAsset, + combinedHourLimit, + currentAmPm, decelerationRate = 0.88, disableInfiniteScroll = false, FlatList = RNFlatList, @@ -37,6 +44,7 @@ const DurationScroll = forwardRef((props initialValue = 0, interval, is12HourPicker, + isAmPmPicker, isDisabled, label, limit, @@ -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( @@ -229,11 +262,15 @@ const DurationScroll = forwardRef((props adjustedLimitedMin={adjustedLimited.min} allowFontScaling={allowFontScaling} amLabel={amLabel} + combinedHourLimit={combinedHourLimit} + currentAmPm={currentAmPm} is12HourPicker={is12HourPicker} + isAmPmPicker={isAmPmPicker} item={item} pickerAmPmPositionStyle={labelPositionStyle} pmLabel={pmLabel} selectedValue={selectedValue} + separateAmPmPicker={separateAmPmPicker} styles={styles} /> ), @@ -242,30 +279,20 @@ const DurationScroll = forwardRef((props adjustedLimited.min, allowFontScaling, amLabel, + combinedHourLimit, + currentAmPm, is12HourPicker, + isAmPmPicker, 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; - }, + (value: number) => getNearestInRange(value, adjustedLimited.min, adjustedLimited.max), [adjustedLimited] ); diff --git a/src/components/DurationScroll/types.ts b/src/components/DurationScroll/types.ts index 0bd2030..9d53c5d 100644 --- a/src/components/DurationScroll/types.ts +++ b/src/components/DurationScroll/types.ts @@ -12,6 +12,8 @@ export interface DurationScrollProps { amLabel?: string; Audio?: any; clickSoundAsset?: SoundAsset; + combinedHourLimit?: Limit; + currentAmPm?: number; decelerationRate?: number | "normal" | "fast"; disableInfiniteScroll?: boolean; FlatList?: any; @@ -19,6 +21,7 @@ export interface DurationScrollProps { initialValue?: number; interval: number; is12HourPicker?: boolean; + isAmPmPicker?: boolean; isDisabled?: boolean; label?: string | React.ReactElement; limit?: Limit; @@ -36,6 +39,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..647e75c 100644 --- a/src/components/PickerItem/PickerItem.tsx +++ b/src/components/PickerItem/PickerItem.tsx @@ -3,6 +3,8 @@ import React from "react"; import { View, Text } from "react-native"; import { isWithinLimit } from "../../utils/isWithinLimit"; +import { combineToHour24 } from "../../utils/separateAmPmHour"; +import type { Limit } from "../DurationScroll/types"; import type { generateStyles } from "../TimerPicker/styles"; interface PickerItemProps { @@ -10,34 +12,59 @@ interface PickerItemProps { adjustedLimitedMin: number; allowFontScaling: boolean; amLabel?: string; + combinedHourLimit?: Limit; + currentAmPm?: number; is12HourPicker?: boolean; + isAmPmPicker?: boolean; item: string; pickerAmPmPositionStyle?: { left: "50%"; marginLeft: number }; pmLabel?: string; selectedValue?: number; + separateAmPmPicker?: boolean; styles: ReturnType; } +const isCombinedHourInRange = (hour24: number, limit: Limit | undefined): boolean => { + if (!limit || (limit.min === undefined && limit.max === undefined)) return true; + return isWithinLimit(hour24, limit.min ?? 0, limit.max ?? 23); +}; + const PickerItem = React.memo( ({ adjustedLimitedMax, adjustedLimitedMin, allowFontScaling, amLabel, + combinedHourLimit, + currentAmPm, is12HourPicker, + isAmPmPicker, 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 represents + // the noon/midnight cycle index 0; every other display value matches its 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 +75,27 @@ 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) { + // The AM/PM column is always freely toggleable so users can navigate to a + // different half of the clock; the hour column handles all limit enforcement + // (greying + snap-back) once AM/PM has been chosen. + isDisabled = false; + } else if (is12HourPicker && separateAmPmPicker) { + // Hour cycle row folds into 24h with the current AM/PM. + isDisabled = + !isNaN(intItem) && + currentAmPm !== undefined && + !isCombinedHourInRange(combineToHour24(intItem, currentAmPm), combinedHourLimit); + } else { + isDisabled = !isWithinLimit(intItem, adjustedLimitedMin, adjustedLimitedMax); + } return ( @@ -65,7 +109,7 @@ const PickerItem = React.memo( > {stringItem} - {is12HourPicker && ( + {is12HourPicker && !separateAmPmPicker && ( {isAm ? amLabel : pmLabel} diff --git a/src/components/TimerPicker/TimerPicker.tsx b/src/components/TimerPicker/TimerPicker.tsx index 43af37c..a17d67d 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 { combineToHour24, splitHour24 } from "../../utils/separateAmPmHour"; +import { findNearestValidCycleIdx } from "../../utils/snapSeparateAmPmHour"; import DurationScroll from "../DurationScroll"; import type { DurationScrollRef } from "../DurationScroll"; import { generateStyles } from "./styles"; @@ -75,11 +77,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 +112,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 +162,91 @@ 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.cycleIdx : safeInitialValue.hours + ); + const [selectedAmPm, setSelectedAmPm] = useState(initialHourSplit.amPm); const [selectedMinutes, setSelectedMinutes] = useState(safeInitialValue.minutes); const [selectedSeconds, setSelectedSeconds] = useState(safeInitialValue.seconds); + // Mirror selectedAmPm into a ref so the hour-cycle snap handler always reads the + // freshest AM/PM context without forcing a callback re-creation per state change. + const selectedAmPmRef = useRef(selectedAmPm); useEffect(() => { + selectedAmPmRef.current = selectedAmPm; + }, [selectedAmPm]); + + 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); + // Snap-aware hour-cycle change handler (separate AM/PM mode). When the user lands on + // a cycleIdx whose combined 24h value falls outside hourLimit, snap to the nearest + // valid cycleIdx given the current AM/PM. If no value in the column's range is valid, + // do not snap. The AM/PM column is intentionally limit-free so users can always + // toggle halves to reach any valid hour. + const handleHourCycleChange = (rawCycleIdx: number) => { + const snapped = findNearestValidCycleIdx(rawCycleIdx, selectedAmPmRef.current, hourLimit); + setSelectedHours(snapped); + if (snapped !== rawCycleIdx) { + hoursDurationScrollRef.current?.setValue(snapped, { animated: true }); + } + }; + + // 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( + () => ({ + get current() { + const cycleIdx = hoursDurationScrollRef.current?.latestDuration.current ?? 0; + const amPm = amPmDurationScrollRef.current?.latestDuration.current ?? 0; + return combineToHour24(cycleIdx, amPm); + }, + }), + [] + ); + useImperativeHandle(ref, () => ({ latestDuration: { days: daysDurationScrollRef.current?.latestDuration, - hours: hoursDurationScrollRef.current?.latestDuration, + hours: useSeparateAmPm + ? (combinedHoursLatestDuration as { current: number }) + : hoursDurationScrollRef.current?.latestDuration, minutes: minutesDurationScrollRef.current?.latestDuration, seconds: secondsDurationScrollRef.current?.latestDuration, }, reset: (options) => { setSelectedDays(safeInitialValue.days); - setSelectedHours(safeInitialValue.hours); + if (useSeparateAmPm) { + setSelectedHours(initialHourSplit.cycleIdx); + 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 +256,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.cycleIdx); + setSelectedAmPm(split.amPm); + hoursDurationScrollRef.current?.setValue(split.cycleIdx, options); + amPmDurationScrollRef.current?.setValue(split.amPm, options); + } else { + setSelectedHours(value.hours); + hoursDurationScrollRef.current?.setValue(value.hours, options); + } } if (value.minutes !== undefined) { setSelectedMinutes(value.minutes); @@ -239,16 +311,18 @@ const TimerPicker = forwardRef((props, ref) => aggressivelyGetLatestDuration={aggressivelyGetLatestDuration} allowFontScaling={allowFontScaling} amLabel={amLabel} + combinedHourLimit={useSeparateAmPm ? hourLimit : undefined} + currentAmPm={useSeparateAmPm ? selectedAmPm : undefined} decelerationRate={decelerationRate} disableInfiniteScroll={disableInfiniteScroll} - initialValue={safeInitialValue.hours} + initialValue={useSeparateAmPm ? initialHourSplit.cycleIdx : safeInitialValue.hours} interval={hourInterval} is12HourPicker={use12HourPicker} isDisabled={hoursPickerIsDisabled} label={hourLabel ?? (!use12HourPicker ? "h" : undefined)} - limit={hourLimit} - maximumValue={maximumHours} - onDurationChange={setSelectedHours} + limit={useSeparateAmPm ? undefined : hourLimit} + maximumValue={useSeparateAmPm ? 11 : maximumHours} + onDurationChange={useSeparateAmPm ? handleHourCycleChange : setSelectedHours} padNumbersWithZero={padHoursWithZero} padWithNItems={safePadWithNItems} pickerColumnWidth={resolvePerColumn(pickerColumnWidth, "hours")} @@ -257,6 +331,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 +389,30 @@ const TimerPicker = forwardRef((props, ref) => {...otherProps} /> ) : null} + {useSeparateAmPm ? ( + + ) : null} ); }); diff --git a/src/components/TimerPicker/types.ts b/src/components/TimerPicker/types.ts index 8144ada..488af61 100644 --- a/src/components/TimerPicker/types.ts +++ b/src/components/TimerPicker/types.ts @@ -94,6 +94,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..20e42e2 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 ? DEFAULT_COLUMN_WIDTH : 0); const styles = generateStyles(customStyles, { hasModalTitle: Boolean(modalTitle), diff --git a/src/tests/PickerItem.test.tsx b/src/tests/PickerItem.test.tsx index 44e0e9c..fbc7790 100644 --- a/src/tests/PickerItem.test.tsx +++ b/src/tests/PickerItem.test.tsx @@ -3,6 +3,7 @@ import React from "react"; import { render } from "@testing-library/react-native"; import type { ReactTestInstance } from "react-test-renderer"; +import type { Limit } from "../components/DurationScroll/types"; import PickerItem from "../components/PickerItem"; import type { generateStyles } from "../components/TimerPicker/styles"; @@ -23,20 +24,30 @@ const styles = { const renderItem = (props: { adjustedLimitedMax: number; adjustedLimitedMin: number; + amLabel?: string; + combinedHourLimit?: Limit; + currentAmPm?: number; is12HourPicker?: boolean; + isAmPmPicker?: boolean; item: string; + pmLabel?: string; selectedValue?: number; + separateAmPmPicker?: boolean; }) => render( ); @@ -174,4 +185,202 @@ 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("does not grey items when no combinedHourLimit is provided", () => { + const { getByText } = renderItem({ + adjustedLimitedMax: 5, + adjustedLimitedMin: 0, + currentAmPm: 0, + is12HourPicker: true, + item: "11", + separateAmPmPicker: true, + }); + expect(isDisabledStyle(getByText("11"))).toBe(false); + }); + + describe("greying with normal hourLimit { min: 9, max: 17 }", () => { + const limit = { max: 17, min: 9 }; + + it.each([ + ["12", 0, true], // 12 AM = 0, out of range + ["08", 0, true], + ["09", 0, false], + ["11", 0, false], + ])("AM (currentAmPm=0): row %s greyed=%s", (row, currentAmPm, greyed) => { + const { getByText } = renderItem({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + combinedHourLimit: limit, + currentAmPm, + is12HourPicker: true, + item: row, + separateAmPmPicker: true, + }); + expect(isDisabledStyle(getByText(row))).toBe(greyed); + }); + + it.each([ + ["12", 1, false], // 12 PM = 12, in range + ["05", 1, false], // 5 PM = 17, in range + ["06", 1, true], // 6 PM = 18, out of range + ["11", 1, true], + ])("PM (currentAmPm=1): row %s greyed=%s", (row, currentAmPm, greyed) => { + const { getByText } = renderItem({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + combinedHourLimit: limit, + currentAmPm, + is12HourPicker: true, + item: row, + separateAmPmPicker: true, + }); + expect(isDisabledStyle(getByText(row))).toBe(greyed); + }); + }); + + describe("greying with wraparound hourLimit { min: 20, max: 5 }", () => { + const limit = { max: 5, min: 20 }; + + it.each([ + ["12", 0, false], // 12 AM = 0, in range + ["05", 0, false], // 5 AM = 5, in range + ["06", 0, true], // 6 AM = 6, out of range + ["11", 0, true], + ])("AM (currentAmPm=0): row %s greyed=%s", (row, currentAmPm, greyed) => { + const { getByText } = renderItem({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + combinedHourLimit: limit, + currentAmPm, + is12HourPicker: true, + item: row, + separateAmPmPicker: true, + }); + expect(isDisabledStyle(getByText(row))).toBe(greyed); + }); + + it.each([ + ["12", 1, true], // 12 PM = 12, out of range + ["07", 1, true], + ["08", 1, false], // 8 PM = 20, in range + ["11", 1, false], // 11 PM = 23, in range + ])("PM (currentAmPm=1): row %s greyed=%s", (row, currentAmPm, greyed) => { + const { getByText } = renderItem({ + adjustedLimitedMax: 11, + adjustedLimitedMin: 0, + combinedHourLimit: limit, + currentAmPm, + is12HourPicker: true, + item: row, + separateAmPmPicker: true, + }); + expect(isDisabledStyle(getByText(row))).toBe(greyed); + }); + }); + }); + + 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("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. + it.each([ + ["AM", { max: 17, min: 9 }], + ["PM", { max: 17, min: 9 }], + ["AM", { max: 5, min: 20 }], + ["PM", { max: 5, min: 20 }], + ])("row %s is never greyed", (row, limit) => { + const { getByText } = renderItem({ + adjustedLimitedMax: 1, + adjustedLimitedMin: 0, + combinedHourLimit: limit, + isAmPmPicker: 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..415b2ef --- /dev/null +++ b/src/tests/snapSeparateAmPmHour.test.ts @@ -0,0 +1,85 @@ +import { findNearestValidCycleIdx } from "../utils/snapSeparateAmPmHour"; + +describe("findNearestValidCycleIdx", () => { + describe("no limit", () => { + it.each([0, 5, 11])("returns the input cycleIdx %i unchanged", (cycleIdx) => { + expect(findNearestValidCycleIdx(cycleIdx, 0, undefined)).toBe(cycleIdx); + expect(findNearestValidCycleIdx(cycleIdx, 1, undefined)).toBe(cycleIdx); + }); + + it("treats an empty Limit object as no limit", () => { + expect(findNearestValidCycleIdx(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 cycleIdx %i snaps to %i", (input, expected) => { + expect(findNearestValidCycleIdx(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 cycleIdx is 5 + [11, 5], + ])("PM cycleIdx %i snaps to %i", (input, expected) => { + expect(findNearestValidCycleIdx(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 cycleIdx %i snaps to %i", (input, expected) => { + expect(findNearestValidCycleIdx(input, 0, limit)).toBe(expected); + }); + + it.each([ + [0, 8], // 12 PM = 12, invalid → nearest valid PM is cycleIdx 8 (= 20) + [7, 8], + [8, 8], // 8 PM = 20, valid + [11, 11], // 11 PM = 23, valid + ])("PM cycleIdx %i snaps to %i", (input, expected) => { + expect(findNearestValidCycleIdx(input, 1, limit)).toBe(expected); + }); + }); + + describe("limit excludes one full half", () => { + it("returns input unchanged when no AM cycleIdx is valid (limit forces PM)", () => { + // limit { min: 13, max: 17 } means only PM hours 13-17 are valid + // No AM cycleIdx (which gives 0 or 1-11) can land in [13, 17]. + expect(findNearestValidCycleIdx(5, 0, { max: 17, min: 13 })).toBe(5); + }); + + it("returns input unchanged when no PM cycleIdx is valid (limit forces AM)", () => { + // limit { min: 1, max: 5 } means only AM hours 1-5 are valid; no PM combo works. + expect(findNearestValidCycleIdx(7, 1, { max: 5, min: 1 })).toBe(7); + }); + }); + + it("respects limit with only min set (max defaults to 23)", () => { + expect(findNearestValidCycleIdx(0, 0, { min: 12 })).toBe(0); // No AM works; falls through to input + expect(findNearestValidCycleIdx(0, 1, { min: 12 })).toBe(0); // 12 PM = 12, valid + }); + + it("respects limit with only max set (min defaults to 0)", () => { + expect(findNearestValidCycleIdx(0, 0, { max: 5 })).toBe(0); // 12 AM = 0, valid + expect(findNearestValidCycleIdx(11, 0, { max: 5 })).toBe(5); + }); +}); 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..b212988 --- /dev/null +++ b/src/utils/separateAmPmHour.ts @@ -0,0 +1,17 @@ +/** + * Helpers for the `separateAmPmPicker` representation. The hour column tracks a + * 0–11 cycle index (where 0 is the noon/midnight "12" slot) and 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; cycleIdx: number } => { + if (hour24 === 0) return { amPm: 0, cycleIdx: 0 }; + if (hour24 === 12) return { amPm: 1, cycleIdx: 0 }; + if (hour24 < 12) return { amPm: 0, cycleIdx: hour24 }; + return { amPm: 1, cycleIdx: hour24 - 12 }; +}; + +export const combineToHour24 = (cycleIdx: number, amPm: number): number => { + if (cycleIdx === 0) return amPm === 1 ? 12 : 0; + return cycleIdx + (amPm === 1 ? 12 : 0); +}; diff --git a/src/utils/snapSeparateAmPmHour.ts b/src/utils/snapSeparateAmPmHour.ts new file mode 100644 index 0000000..cd36c49 --- /dev/null +++ b/src/utils/snapSeparateAmPmHour.ts @@ -0,0 +1,42 @@ +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 cycleIdx in [0, 11] nearest to `rawCycleIdx` such that + * `(cycleIdx, currentAmPm)` is within `hourLimit`. If no value in the column's + * range satisfies the limit, `rawCycleIdx` is returned unchanged (no snap). + */ +export const findNearestValidCycleIdx = ( + rawCycleIdx: number, + currentAmPm: number, + hourLimit: Limit | undefined +): number => { + const resolved = resolveLimit(hourLimit); + if (!resolved) return rawCycleIdx; + + if (isWithinLimit(combineToHour24(rawCycleIdx, currentAmPm), resolved.min, resolved.max)) { + return rawCycleIdx; + } + + let best: number | null = null; + let bestDistance = Infinity; + for (let i = 0; i <= 11; i++) { + if (!isWithinLimit(combineToHour24(i, currentAmPm), resolved.min, resolved.max)) continue; + const distance = Math.abs(i - rawCycleIdx); + if (distance < bestDistance) { + bestDistance = distance; + best = i; + } + } + + return best ?? rawCycleIdx; +}; From 5751edabd04e4e0742181b6b4c3629442193ef09 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 4 May 2026 23:11:18 +0100 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=8E=A8=20Add=20separateAmPmItem=20/?= =?UTF-8?q?=20selectedSeparateAmPmItem=20style=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + examples/example-expo/App.tsx | 6 +- src/components/PickerItem/PickerItem.tsx | 2 + src/components/TimerPicker/styles.ts | 8 +++ src/tests/DurationScroll.test.tsx | 2 + src/tests/PickerItem.test.tsx | 72 ++++++++++++++++++++++++ 6 files changed, 91 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f672984..599c33a 100644 --- a/README.md +++ b/README.md @@ -610,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 798b7fd..39ca9d3 100644 --- a/examples/example-expo/App.tsx +++ b/examples/example-expo/App.tsx @@ -209,7 +209,11 @@ export default function App() { pmLabel="PM" separateAmPmPicker setIsVisible={setShowPickerSeparateAmPm} - styles={{ theme: "light" }} + styles={{ + selectedSeparateAmPmItem: { color: "#1B6EF1" }, + separateAmPmItem: { fontSize: 16, fontWeight: "600" }, + theme: "light", + }} use12HourPicker visible={showPickerSeparateAmPm} /> diff --git a/src/components/PickerItem/PickerItem.tsx b/src/components/PickerItem/PickerItem.tsx index 647e75c..9aec3c0 100644 --- a/src/components/PickerItem/PickerItem.tsx +++ b/src/components/PickerItem/PickerItem.tsx @@ -105,6 +105,8 @@ const PickerItem = React.memo( styles.pickerItem, isSelected && styles.selectedPickerItem, isDisabled ? styles.disabledPickerItem : {}, + isAmPmPicker && styles.separateAmPmItem, + isAmPmPicker && isSelected && styles.selectedSeparateAmPmItem, ]} > {stringItem} diff --git a/src/components/TimerPicker/styles.ts b/src/components/TimerPicker/styles.ts index cf4da5f..f60f1d2 100644 --- a/src/components/TimerPicker/styles.ts +++ b/src/components/TimerPicker/styles.ts @@ -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/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 fbc7790..c1560be 100644 --- a/src/tests/PickerItem.test.tsx +++ b/src/tests/PickerItem.test.tsx @@ -19,6 +19,8 @@ const styles = { pickerLabel: {}, pickerLabelContainer: {}, selectedPickerItem: { fontWeight: "bold" as const }, + selectedSeparateAmPmItem: {}, + separateAmPmItem: {}, } as ReturnType; const renderItem = (props: { @@ -363,6 +365,76 @@ describe("PickerItem", () => { 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. From 04c0ca77aca11b2503eda685f94a934e3f54d5bd Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 4 May 2026 23:57:06 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Consolidate=20snap=20+?= =?UTF-8?q?=20greying=20via=20callbacks;=20honour=20hourInterval;=20tidy?= =?UTF-8?q?=20refs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DurationScroll gains `getValidValue` + `isItemDisabled` callback props. TimerPicker injects cross-column context (current AM/PM, hourLimit, hourInterval) through these instead of plumbing combinedHourLimit / currentAmPm through DurationScroll → PickerItem. - One snap path: DurationScroll's onMomentumScrollEnd calls the validate callback, no more reentrant setValue from TimerPicker. - findNearestValidCycleIdx now takes interval and iterates 0..11 step interval, so hourInterval > 1 no longer snaps to a non-rendered cycleIdx. Adds parameterised interval coverage to the test suite. - Drop dead selectedAmPmRef + sync useEffect. - Replace the synthetic `RefObject` cast on latestDuration.hours with a proper `LatestDurationRef = { readonly current: number }` type used by both TimerPickerRef and TimerPickerModalRef. - Add "amPm" to PickerColumn so pickerColumnWidth.amPm works and the modal width math routes through the existing resolveColumnWidth helper. --- README.md | 2 +- .../DurationScroll/DurationScroll.tsx | 17 ++-- src/components/DurationScroll/types.ts | 15 ++- src/components/PickerItem/PickerItem.tsx | 25 +---- src/components/TimerPicker/TimerPicker.tsx | 44 ++++----- src/components/TimerPicker/styles.ts | 2 +- src/components/TimerPicker/types.ts | 11 ++- .../TimerPickerModal/TimerPickerModal.tsx | 2 +- src/components/TimerPickerModal/types.ts | 12 +-- src/tests/PickerItem.test.tsx | 97 ++++++------------- src/tests/snapSeparateAmPmHour.test.ts | 31 ++++++ src/utils/snapSeparateAmPmHour.ts | 12 ++- 12 files changed, 134 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index 599c33a..7cec30b 100644 --- a/README.md +++ b/README.md @@ -566,7 +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` is 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. Note: `maximumHours` is currently ignored in this mode — the column always shows the full 12-hour cycle. | 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 | diff --git a/src/components/DurationScroll/DurationScroll.tsx b/src/components/DurationScroll/DurationScroll.tsx index 655d182..e1b4872 100644 --- a/src/components/DurationScroll/DurationScroll.tsx +++ b/src/components/DurationScroll/DurationScroll.tsx @@ -35,17 +35,17 @@ const DurationScroll = forwardRef((props amLabel, Audio, clickSoundAsset, - combinedHourLimit, - currentAmPm, decelerationRate = 0.88, disableInfiniteScroll = false, FlatList = RNFlatList, + getValidValue, Haptics, initialValue = 0, interval, is12HourPicker, isAmPmPicker, isDisabled, + isItemDisabled, label, limit, LinearGradient, @@ -262,10 +262,9 @@ const DurationScroll = forwardRef((props adjustedLimitedMin={adjustedLimited.min} allowFontScaling={allowFontScaling} amLabel={amLabel} - combinedHourLimit={combinedHourLimit} - currentAmPm={currentAmPm} is12HourPicker={is12HourPicker} isAmPmPicker={isAmPmPicker} + isItemDisabled={isItemDisabled} item={item} pickerAmPmPositionStyle={labelPositionStyle} pmLabel={pmLabel} @@ -279,10 +278,9 @@ const DurationScroll = forwardRef((props adjustedLimited.min, allowFontScaling, amLabel, - combinedHourLimit, - currentAmPm, is12HourPicker, isAmPmPicker, + isItemDisabled, labelPositionStyle, pmLabel, selectedValue, @@ -292,8 +290,11 @@ const DurationScroll = forwardRef((props ); const getNearestInRangeValue = useCallback( - (value: number) => getNearestInRange(value, adjustedLimited.min, adjustedLimited.max), - [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 9d53c5d..c63223b 100644 --- a/src/components/DurationScroll/types.ts +++ b/src/components/DurationScroll/types.ts @@ -12,17 +12,28 @@ export interface DurationScrollProps { amLabel?: string; Audio?: any; clickSoundAsset?: SoundAsset; - combinedHourLimit?: Limit; - currentAmPm?: number; 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; diff --git a/src/components/PickerItem/PickerItem.tsx b/src/components/PickerItem/PickerItem.tsx index 9aec3c0..96841a4 100644 --- a/src/components/PickerItem/PickerItem.tsx +++ b/src/components/PickerItem/PickerItem.tsx @@ -3,8 +3,6 @@ import React from "react"; import { View, Text } from "react-native"; import { isWithinLimit } from "../../utils/isWithinLimit"; -import { combineToHour24 } from "../../utils/separateAmPmHour"; -import type { Limit } from "../DurationScroll/types"; import type { generateStyles } from "../TimerPicker/styles"; interface PickerItemProps { @@ -12,10 +10,9 @@ interface PickerItemProps { adjustedLimitedMin: number; allowFontScaling: boolean; amLabel?: string; - combinedHourLimit?: Limit; - currentAmPm?: number; is12HourPicker?: boolean; isAmPmPicker?: boolean; + isItemDisabled?: (value: number) => boolean; item: string; pickerAmPmPositionStyle?: { left: "50%"; marginLeft: number }; pmLabel?: string; @@ -24,21 +21,15 @@ interface PickerItemProps { styles: ReturnType; } -const isCombinedHourInRange = (hour24: number, limit: Limit | undefined): boolean => { - if (!limit || (limit.min === undefined && limit.max === undefined)) return true; - return isWithinLimit(hour24, limit.min ?? 0, limit.max ?? 23); -}; - const PickerItem = React.memo( ({ adjustedLimitedMax, adjustedLimitedMin, allowFontScaling, amLabel, - combinedHourLimit, - currentAmPm, is12HourPicker, isAmPmPicker, + isItemDisabled, item, pickerAmPmPositionStyle, pmLabel, @@ -83,16 +74,10 @@ const PickerItem = React.memo( let isDisabled: boolean; if (isAmPmPicker) { - // The AM/PM column is always freely toggleable so users can navigate to a - // different half of the clock; the hour column handles all limit enforcement - // (greying + snap-back) once AM/PM has been chosen. + // AM/PM is always freely toggleable; the hour column does all limit enforcement. isDisabled = false; - } else if (is12HourPicker && separateAmPmPicker) { - // Hour cycle row folds into 24h with the current AM/PM. - isDisabled = - !isNaN(intItem) && - currentAmPm !== undefined && - !isCombinedHourInRange(combineToHour24(intItem, currentAmPm), combinedHourLimit); + } else if (isItemDisabled) { + isDisabled = !isNaN(intItem) && isItemDisabled(intItem); } else { isDisabled = !isWithinLimit(intItem, adjustedLimitedMin, adjustedLimitedMax); } diff --git a/src/components/TimerPicker/TimerPicker.tsx b/src/components/TimerPicker/TimerPicker.tsx index a17d67d..f0b501d 100644 --- a/src/components/TimerPicker/TimerPicker.tsx +++ b/src/components/TimerPicker/TimerPicker.tsx @@ -10,6 +10,7 @@ import React, { import { View } from "react-native"; import { getSafeInitialValue } from "../../utils/getSafeInitialValue"; +import { isWithinLimit } from "../../utils/isWithinLimit"; import { combineToHour24, splitHour24 } from "../../utils/separateAmPmHour"; import { findNearestValidCycleIdx } from "../../utils/snapSeparateAmPmHour"; import DurationScroll from "../DurationScroll"; @@ -175,13 +176,6 @@ const TimerPicker = forwardRef((props, ref) => const [selectedMinutes, setSelectedMinutes] = useState(safeInitialValue.minutes); const [selectedSeconds, setSelectedSeconds] = useState(safeInitialValue.seconds); - // Mirror selectedAmPm into a ref so the hour-cycle snap handler always reads the - // freshest AM/PM context without forcing a callback re-creation per state change. - const selectedAmPmRef = useRef(selectedAmPm); - useEffect(() => { - selectedAmPmRef.current = selectedAmPm; - }, [selectedAmPm]); - useEffect(() => { const hours = useSeparateAmPm ? combineToHour24(selectedHours, selectedAmPm) : selectedHours; onDurationChange?.({ @@ -199,22 +193,27 @@ const TimerPicker = forwardRef((props, ref) => const minutesDurationScrollRef = useRef(null); const secondsDurationScrollRef = useRef(null); - // Snap-aware hour-cycle change handler (separate AM/PM mode). When the user lands on - // a cycleIdx whose combined 24h value falls outside hourLimit, snap to the nearest - // valid cycleIdx given the current AM/PM. If no value in the column's range is valid, - // do not snap. The AM/PM column is intentionally limit-free so users can always - // toggle halves to reach any valid hour. - const handleHourCycleChange = (rawCycleIdx: number) => { - const snapped = findNearestValidCycleIdx(rawCycleIdx, selectedAmPmRef.current, hourLimit); - setSelectedHours(snapped); - if (snapped !== rawCycleIdx) { - hoursDurationScrollRef.current?.setValue(snapped, { animated: true }); + // 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 getValidHourCycleIdx = (rawCycleIdx: number) => + findNearestValidCycleIdx(rawCycleIdx, selectedAmPm, hourLimit, hourInterval); + + const isHourCycleDisabled = (cycleIdx: number) => { + if (!hourLimit || (hourLimit.min === undefined && hourLimit.max === undefined)) { + return false; } + return !isWithinLimit( + combineToHour24(cycleIdx, 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( + const combinedHoursLatestDuration = useMemo<{ readonly current: number }>( () => ({ get current() { const cycleIdx = hoursDurationScrollRef.current?.latestDuration.current ?? 0; @@ -229,7 +228,7 @@ const TimerPicker = forwardRef((props, ref) => latestDuration: { days: daysDurationScrollRef.current?.latestDuration, hours: useSeparateAmPm - ? (combinedHoursLatestDuration as { current: number }) + ? combinedHoursLatestDuration : hoursDurationScrollRef.current?.latestDuration, minutes: minutesDurationScrollRef.current?.latestDuration, seconds: secondsDurationScrollRef.current?.latestDuration, @@ -311,18 +310,18 @@ const TimerPicker = forwardRef((props, ref) => aggressivelyGetLatestDuration={aggressivelyGetLatestDuration} allowFontScaling={allowFontScaling} amLabel={amLabel} - combinedHourLimit={useSeparateAmPm ? hourLimit : undefined} - currentAmPm={useSeparateAmPm ? selectedAmPm : undefined} decelerationRate={decelerationRate} disableInfiniteScroll={disableInfiniteScroll} + getValidValue={useSeparateAmPm ? getValidHourCycleIdx : undefined} initialValue={useSeparateAmPm ? initialHourSplit.cycleIdx : safeInitialValue.hours} interval={hourInterval} is12HourPicker={use12HourPicker} isDisabled={hoursPickerIsDisabled} + isItemDisabled={useSeparateAmPm ? isHourCycleDisabled : undefined} label={hourLabel ?? (!use12HourPicker ? "h" : undefined)} limit={useSeparateAmPm ? undefined : hourLimit} maximumValue={useSeparateAmPm ? 11 : maximumHours} - onDurationChange={useSeparateAmPm ? handleHourCycleChange : setSelectedHours} + onDurationChange={setSelectedHours} padNumbersWithZero={padHoursWithZero} padWithNItems={safePadWithNItems} pickerColumnWidth={resolvePerColumn(pickerColumnWidth, "hours")} @@ -404,6 +403,7 @@ const TimerPicker = forwardRef((props, ref) => maximumValue={1} onDurationChange={setSelectedAmPm} padWithNItems={safePadWithNItems} + pickerColumnWidth={resolvePerColumn(pickerColumnWidth, "amPm")} pmLabel={pmLabel} repeatNumbersNTimes={1} repeatNumbersNTimesNotExplicitlySet={false} diff --git a/src/components/TimerPicker/styles.ts b/src/components/TimerPicker/styles.ts index f60f1d2..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>; diff --git a/src/components/TimerPicker/types.ts b/src/components/TimerPicker/types.ts index 488af61..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: ( diff --git a/src/components/TimerPickerModal/TimerPickerModal.tsx b/src/components/TimerPickerModal/TimerPickerModal.tsx index 20e42e2..5fd5ca9 100644 --- a/src/components/TimerPickerModal/TimerPickerModal.tsx +++ b/src/components/TimerPickerModal/TimerPickerModal.tsx @@ -69,7 +69,7 @@ const TimerPickerModal = forwardRef( (!hideHours ? resolveColumnWidth(pickerColumnWidth, "hours") : 0) + (!hideMinutes ? resolveColumnWidth(pickerColumnWidth, "minutes") : 0) + (!hideSeconds ? resolveColumnWidth(pickerColumnWidth, "seconds") : 0) + - (hasSeparateAmPmColumn ? DEFAULT_COLUMN_WIDTH : 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/PickerItem.test.tsx b/src/tests/PickerItem.test.tsx index c1560be..7da8ade 100644 --- a/src/tests/PickerItem.test.tsx +++ b/src/tests/PickerItem.test.tsx @@ -3,7 +3,6 @@ import React from "react"; import { render } from "@testing-library/react-native"; import type { ReactTestInstance } from "react-test-renderer"; -import type { Limit } from "../components/DurationScroll/types"; import PickerItem from "../components/PickerItem"; import type { generateStyles } from "../components/TimerPicker/styles"; @@ -27,10 +26,9 @@ const renderItem = (props: { adjustedLimitedMax: number; adjustedLimitedMin: number; amLabel?: string; - combinedHourLimit?: Limit; - currentAmPm?: number; is12HourPicker?: boolean; isAmPmPicker?: boolean; + isItemDisabled?: (value: number) => boolean; item: string; pmLabel?: string; selectedValue?: number; @@ -42,10 +40,9 @@ const renderItem = (props: { adjustedLimitedMin={props.adjustedLimitedMin} allowFontScaling={false} amLabel={props.amLabel ?? "AM"} - combinedHourLimit={props.combinedHourLimit} - currentAmPm={props.currentAmPm} is12HourPicker={props.is12HourPicker} isAmPmPicker={props.isAmPmPicker} + isItemDisabled={props.isItemDisabled} item={props.item} pmLabel={props.pmLabel ?? "PM"} selectedValue={props.selectedValue} @@ -225,11 +222,12 @@ describe("PickerItem", () => { expect(isSelectedStyle(getByText("03"))).toBe(true); }); - it("does not grey items when no combinedHourLimit is provided", () => { + 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: 5, + adjustedLimitedMax: 11, adjustedLimitedMin: 0, - currentAmPm: 0, is12HourPicker: true, item: "11", separateAmPmPicker: true, @@ -237,83 +235,55 @@ describe("PickerItem", () => { expect(isDisabledStyle(getByText("11"))).toBe(false); }); - describe("greying with normal hourLimit { min: 9, max: 17 }", () => { - const limit = { max: 17, min: 9 }; - - it.each([ - ["12", 0, true], // 12 AM = 0, out of range - ["08", 0, true], - ["09", 0, false], - ["11", 0, false], - ])("AM (currentAmPm=0): row %s greyed=%s", (row, currentAmPm, greyed) => { + describe("greying via isItemDisabled callback", () => { + it("calls isItemDisabled with the parsed cycleIdx ('12' → 0)", () => { + const isItemDisabled = jest.fn((value: number) => value === 0); const { getByText } = renderItem({ adjustedLimitedMax: 11, adjustedLimitedMin: 0, - combinedHourLimit: limit, - currentAmPm, is12HourPicker: true, - item: row, + isItemDisabled, + item: "12", separateAmPmPicker: true, }); - expect(isDisabledStyle(getByText(row))).toBe(greyed); + expect(isItemDisabled).toHaveBeenCalledWith(0); + expect(isDisabledStyle(getByText("12"))).toBe(true); }); - it.each([ - ["12", 1, false], // 12 PM = 12, in range - ["05", 1, false], // 5 PM = 17, in range - ["06", 1, true], // 6 PM = 18, out of range - ["11", 1, true], - ])("PM (currentAmPm=1): row %s greyed=%s", (row, currentAmPm, greyed) => { + it("calls isItemDisabled with the parsed cycleIdx ('05' → 5)", () => { + const isItemDisabled = jest.fn(() => false); const { getByText } = renderItem({ adjustedLimitedMax: 11, adjustedLimitedMin: 0, - combinedHourLimit: limit, - currentAmPm, is12HourPicker: true, - item: row, + isItemDisabled, + item: "05", separateAmPmPicker: true, }); - expect(isDisabledStyle(getByText(row))).toBe(greyed); + expect(isItemDisabled).toHaveBeenCalledWith(5); + expect(isDisabledStyle(getByText("05"))).toBe(false); }); - }); - describe("greying with wraparound hourLimit { min: 20, max: 5 }", () => { - const limit = { max: 5, min: 20 }; - - it.each([ - ["12", 0, false], // 12 AM = 0, in range - ["05", 0, false], // 5 AM = 5, in range - ["06", 0, true], // 6 AM = 6, out of range - ["11", 0, true], - ])("AM (currentAmPm=0): row %s greyed=%s", (row, currentAmPm, greyed) => { - const { getByText } = renderItem({ + it("greys when callback returns true, not when false", () => { + const { getByText: a } = renderItem({ adjustedLimitedMax: 11, adjustedLimitedMin: 0, - combinedHourLimit: limit, - currentAmPm, is12HourPicker: true, - item: row, + isItemDisabled: () => true, + item: "07", separateAmPmPicker: true, }); - expect(isDisabledStyle(getByText(row))).toBe(greyed); - }); + expect(isDisabledStyle(a("07"))).toBe(true); - it.each([ - ["12", 1, true], // 12 PM = 12, out of range - ["07", 1, true], - ["08", 1, false], // 8 PM = 20, in range - ["11", 1, false], // 11 PM = 23, in range - ])("PM (currentAmPm=1): row %s greyed=%s", (row, currentAmPm, greyed) => { - const { getByText } = renderItem({ + const { getByText: b } = renderItem({ adjustedLimitedMax: 11, adjustedLimitedMin: 0, - combinedHourLimit: limit, - currentAmPm, is12HourPicker: true, - item: row, + isItemDisabled: () => false, + item: "07", separateAmPmPicker: true, }); - expect(isDisabledStyle(getByText(row))).toBe(greyed); + expect(isDisabledStyle(b("07"))).toBe(false); }); }); }); @@ -438,17 +408,14 @@ describe("PickerItem", () => { 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. - it.each([ - ["AM", { max: 17, min: 9 }], - ["PM", { max: 17, min: 9 }], - ["AM", { max: 5, min: 20 }], - ["PM", { max: 5, min: 20 }], - ])("row %s is never greyed", (row, limit) => { + // 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, - combinedHourLimit: limit, isAmPmPicker: true, + isItemDisabled: () => true, item: row, }); expect(isDisabledStyle(getByText(row))).toBe(false); diff --git a/src/tests/snapSeparateAmPmHour.test.ts b/src/tests/snapSeparateAmPmHour.test.ts index 415b2ef..8dafeb0 100644 --- a/src/tests/snapSeparateAmPmHour.test.ts +++ b/src/tests/snapSeparateAmPmHour.test.ts @@ -82,4 +82,35 @@ describe("findNearestValidCycleIdx", () => { expect(findNearestValidCycleIdx(0, 0, { max: 5 })).toBe(0); // 12 AM = 0, valid expect(findNearestValidCycleIdx(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 a cycleIdx not present in the column. + it.each([ + // interval=2 → cycle indices [0, 2, 4, 6, 8, 10] + [2, 0, 0, { max: 17, min: 9 }, 10], // raw 0 AM invalid; nearest valid AM cycleIdx 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 → cycle indices [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 cycleIdx is 3 (3 PM=15) + // interval=4 → cycle indices [0, 4, 8] + [4, 0, 0, { max: 17, min: 9 }, 0], // no AM cycleIdx 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(findNearestValidCycleIdx(raw, amPm, limit, interval)).toBe(expected); + }); + + it("never returns a cycleIdx outside the rendered set when called with a valid raw", () => { + // interval=2: rendered cycle indices are {0,2,4,6,8,10}. The raw cycleIdx 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 = findNearestValidCycleIdx(raw, 0, limit, 2); + expect(renderedSet.includes(result)).toBe(true); + } + }); + }); }); diff --git a/src/utils/snapSeparateAmPmHour.ts b/src/utils/snapSeparateAmPmHour.ts index cd36c49..d2b78d0 100644 --- a/src/utils/snapSeparateAmPmHour.ts +++ b/src/utils/snapSeparateAmPmHour.ts @@ -11,14 +11,16 @@ const resolveLimit = (limit: Limit | undefined): { max: number; min: number } | }; /** - * Returns the cycleIdx in [0, 11] nearest to `rawCycleIdx` such that - * `(cycleIdx, currentAmPm)` is within `hourLimit`. If no value in the column's - * range satisfies the limit, `rawCycleIdx` is returned unchanged (no snap). + * Returns the cycleIdx in the rendered hour column nearest to `rawCycleIdx` such that + * `(cycleIdx, 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, `rawCycleIdx` is returned unchanged. */ export const findNearestValidCycleIdx = ( rawCycleIdx: number, currentAmPm: number, - hourLimit: Limit | undefined + hourLimit: Limit | undefined, + interval = 1 ): number => { const resolved = resolveLimit(hourLimit); if (!resolved) return rawCycleIdx; @@ -29,7 +31,7 @@ export const findNearestValidCycleIdx = ( let best: number | null = null; let bestDistance = Infinity; - for (let i = 0; i <= 11; i++) { + for (let i = 0; i < 12; i += interval) { if (!isWithinLimit(combineToHour24(i, currentAmPm), resolved.min, resolved.max)) continue; const distance = Math.abs(i - rawCycleIdx); if (distance < bestDistance) { From c32deb0c11aebd34e88467f4f788ee917e2ecc15 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 5 May 2026 09:05:37 +0100 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20Rename=20cycleIdx?= =?UTF-8?q?=20=E2=86=92=20hourSlot=20for=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cycleIdx was internal jargon. hourSlot reads naturally as 'a slot in the 12-hour clock-face column' where slot 0 is the 12 (noon/midnight) position. splitHour24 returns { amPm, hourSlot }; findNearestValidHourSlot replaces findNearestValidCycleIdx; getValidHourSlot/isHourSlotDisabled in TimerPicker. --- src/components/PickerItem/PickerItem.tsx | 4 +- src/components/TimerPicker/TimerPicker.tsx | 28 ++++----- src/tests/PickerItem.test.tsx | 4 +- src/tests/snapSeparateAmPmHour.test.ts | 72 +++++++++++----------- src/utils/separateAmPmHour.ts | 23 +++---- src/utils/snapSeparateAmPmHour.ts | 23 +++---- 6 files changed, 78 insertions(+), 76 deletions(-) diff --git a/src/components/PickerItem/PickerItem.tsx b/src/components/PickerItem/PickerItem.tsx index 96841a4..0d1b21d 100644 --- a/src/components/PickerItem/PickerItem.tsx +++ b/src/components/PickerItem/PickerItem.tsx @@ -51,8 +51,8 @@ const PickerItem = React.memo( intItem = NaN; } } else if (is12HourPicker && separateAmPmPicker) { - // Hour column in clock-face form (12, 1, 2, ..., 11). The "12" slot represents - // the noon/midnight cycle index 0; every other display value matches its index. + // 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) { diff --git a/src/components/TimerPicker/TimerPicker.tsx b/src/components/TimerPicker/TimerPicker.tsx index f0b501d..f6729ad 100644 --- a/src/components/TimerPicker/TimerPicker.tsx +++ b/src/components/TimerPicker/TimerPicker.tsx @@ -12,7 +12,7 @@ import { View } from "react-native"; import { getSafeInitialValue } from "../../utils/getSafeInitialValue"; import { isWithinLimit } from "../../utils/isWithinLimit"; import { combineToHour24, splitHour24 } from "../../utils/separateAmPmHour"; -import { findNearestValidCycleIdx } from "../../utils/snapSeparateAmPmHour"; +import { findNearestValidHourSlot } from "../../utils/snapSeparateAmPmHour"; import DurationScroll from "../DurationScroll"; import type { DurationScrollRef } from "../DurationScroll"; import { generateStyles } from "./styles"; @@ -170,7 +170,7 @@ const TimerPicker = forwardRef((props, ref) => const [selectedDays, setSelectedDays] = useState(safeInitialValue.days); const [selectedHours, setSelectedHours] = useState( - useSeparateAmPm ? initialHourSplit.cycleIdx : safeInitialValue.hours + useSeparateAmPm ? initialHourSplit.hourSlot : safeInitialValue.hours ); const [selectedAmPm, setSelectedAmPm] = useState(initialHourSplit.amPm); const [selectedMinutes, setSelectedMinutes] = useState(safeInitialValue.minutes); @@ -197,15 +197,15 @@ const TimerPicker = forwardRef((props, ref) => // 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 getValidHourCycleIdx = (rawCycleIdx: number) => - findNearestValidCycleIdx(rawCycleIdx, selectedAmPm, hourLimit, hourInterval); + const getValidHourSlot = (rawHourSlot: number) => + findNearestValidHourSlot(rawHourSlot, selectedAmPm, hourLimit, hourInterval); - const isHourCycleDisabled = (cycleIdx: number) => { + const isHourSlotDisabled = (hourSlot: number) => { if (!hourLimit || (hourLimit.min === undefined && hourLimit.max === undefined)) { return false; } return !isWithinLimit( - combineToHour24(cycleIdx, selectedAmPm), + combineToHour24(hourSlot, selectedAmPm), hourLimit.min ?? 0, hourLimit.max ?? 23 ); @@ -216,9 +216,9 @@ const TimerPicker = forwardRef((props, ref) => const combinedHoursLatestDuration = useMemo<{ readonly current: number }>( () => ({ get current() { - const cycleIdx = hoursDurationScrollRef.current?.latestDuration.current ?? 0; + const hourSlot = hoursDurationScrollRef.current?.latestDuration.current ?? 0; const amPm = amPmDurationScrollRef.current?.latestDuration.current ?? 0; - return combineToHour24(cycleIdx, amPm); + return combineToHour24(hourSlot, amPm); }, }), [] @@ -236,7 +236,7 @@ const TimerPicker = forwardRef((props, ref) => reset: (options) => { setSelectedDays(safeInitialValue.days); if (useSeparateAmPm) { - setSelectedHours(initialHourSplit.cycleIdx); + setSelectedHours(initialHourSplit.hourSlot); setSelectedAmPm(initialHourSplit.amPm); } else { setSelectedHours(safeInitialValue.hours); @@ -257,9 +257,9 @@ const TimerPicker = forwardRef((props, ref) => if (value.hours !== undefined) { if (useSeparateAmPm) { const split = splitHour24(value.hours); - setSelectedHours(split.cycleIdx); + setSelectedHours(split.hourSlot); setSelectedAmPm(split.amPm); - hoursDurationScrollRef.current?.setValue(split.cycleIdx, options); + hoursDurationScrollRef.current?.setValue(split.hourSlot, options); amPmDurationScrollRef.current?.setValue(split.amPm, options); } else { setSelectedHours(value.hours); @@ -312,12 +312,12 @@ const TimerPicker = forwardRef((props, ref) => amLabel={amLabel} decelerationRate={decelerationRate} disableInfiniteScroll={disableInfiniteScroll} - getValidValue={useSeparateAmPm ? getValidHourCycleIdx : undefined} - initialValue={useSeparateAmPm ? initialHourSplit.cycleIdx : safeInitialValue.hours} + getValidValue={useSeparateAmPm ? getValidHourSlot : undefined} + initialValue={useSeparateAmPm ? initialHourSplit.hourSlot : safeInitialValue.hours} interval={hourInterval} is12HourPicker={use12HourPicker} isDisabled={hoursPickerIsDisabled} - isItemDisabled={useSeparateAmPm ? isHourCycleDisabled : undefined} + isItemDisabled={useSeparateAmPm ? isHourSlotDisabled : undefined} label={hourLabel ?? (!use12HourPicker ? "h" : undefined)} limit={useSeparateAmPm ? undefined : hourLimit} maximumValue={useSeparateAmPm ? 11 : maximumHours} diff --git a/src/tests/PickerItem.test.tsx b/src/tests/PickerItem.test.tsx index 7da8ade..db51a18 100644 --- a/src/tests/PickerItem.test.tsx +++ b/src/tests/PickerItem.test.tsx @@ -236,7 +236,7 @@ describe("PickerItem", () => { }); describe("greying via isItemDisabled callback", () => { - it("calls isItemDisabled with the parsed cycleIdx ('12' → 0)", () => { + it("calls isItemDisabled with the parsed hourSlot ('12' → 0)", () => { const isItemDisabled = jest.fn((value: number) => value === 0); const { getByText } = renderItem({ adjustedLimitedMax: 11, @@ -250,7 +250,7 @@ describe("PickerItem", () => { expect(isDisabledStyle(getByText("12"))).toBe(true); }); - it("calls isItemDisabled with the parsed cycleIdx ('05' → 5)", () => { + it("calls isItemDisabled with the parsed hourSlot ('05' → 5)", () => { const isItemDisabled = jest.fn(() => false); const { getByText } = renderItem({ adjustedLimitedMax: 11, diff --git a/src/tests/snapSeparateAmPmHour.test.ts b/src/tests/snapSeparateAmPmHour.test.ts index 8dafeb0..0826f0d 100644 --- a/src/tests/snapSeparateAmPmHour.test.ts +++ b/src/tests/snapSeparateAmPmHour.test.ts @@ -1,14 +1,14 @@ -import { findNearestValidCycleIdx } from "../utils/snapSeparateAmPmHour"; +import { findNearestValidHourSlot } from "../utils/snapSeparateAmPmHour"; -describe("findNearestValidCycleIdx", () => { +describe("findNearestValidHourSlot", () => { describe("no limit", () => { - it.each([0, 5, 11])("returns the input cycleIdx %i unchanged", (cycleIdx) => { - expect(findNearestValidCycleIdx(cycleIdx, 0, undefined)).toBe(cycleIdx); - expect(findNearestValidCycleIdx(cycleIdx, 1, undefined)).toBe(cycleIdx); + 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(findNearestValidCycleIdx(7, 0, {})).toBe(7); + expect(findNearestValidHourSlot(7, 0, {})).toBe(7); }); }); @@ -22,18 +22,18 @@ describe("findNearestValidCycleIdx", () => { [9, 9], [10, 10], [11, 11], - ])("AM cycleIdx %i snaps to %i", (input, expected) => { - expect(findNearestValidCycleIdx(input, 0, limit)).toBe(expected); + ])("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 cycleIdx is 5 + [6, 5], // 6 PM = 18, invalid → nearest valid PM hourSlot is 5 [11, 5], - ])("PM cycleIdx %i snaps to %i", (input, expected) => { - expect(findNearestValidCycleIdx(input, 1, limit)).toBe(expected); + ])("PM hourSlot %i snaps to %i", (input, expected) => { + expect(findNearestValidHourSlot(input, 1, limit)).toBe(expected); }); }); @@ -46,69 +46,69 @@ describe("findNearestValidCycleIdx", () => { [6, 5], // 6 AM = 6, invalid → nearest valid AM is 5 [10, 5], [11, 5], - ])("AM cycleIdx %i snaps to %i", (input, expected) => { - expect(findNearestValidCycleIdx(input, 0, limit)).toBe(expected); + ])("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 cycleIdx 8 (= 20) + [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 cycleIdx %i snaps to %i", (input, expected) => { - expect(findNearestValidCycleIdx(input, 1, limit)).toBe(expected); + ])("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 cycleIdx is valid (limit forces PM)", () => { + 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 cycleIdx (which gives 0 or 1-11) can land in [13, 17]. - expect(findNearestValidCycleIdx(5, 0, { max: 17, min: 13 })).toBe(5); + // 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 cycleIdx is valid (limit forces AM)", () => { + 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(findNearestValidCycleIdx(7, 1, { max: 5, min: 1 })).toBe(7); + expect(findNearestValidHourSlot(7, 1, { max: 5, min: 1 })).toBe(7); }); }); it("respects limit with only min set (max defaults to 23)", () => { - expect(findNearestValidCycleIdx(0, 0, { min: 12 })).toBe(0); // No AM works; falls through to input - expect(findNearestValidCycleIdx(0, 1, { min: 12 })).toBe(0); // 12 PM = 12, valid + 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(findNearestValidCycleIdx(0, 0, { max: 5 })).toBe(0); // 12 AM = 0, valid - expect(findNearestValidCycleIdx(11, 0, { max: 5 })).toBe(5); + 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 a cycleIdx not present in the column. + // helper must mirror that so it never returns an hourSlot not present in the column. it.each([ - // interval=2 → cycle indices [0, 2, 4, 6, 8, 10] - [2, 0, 0, { max: 17, min: 9 }, 10], // raw 0 AM invalid; nearest valid AM cycleIdx is 10 + // 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 → cycle indices [0, 3, 6, 9] + // 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 cycleIdx is 3 (3 PM=15) - // interval=4 → cycle indices [0, 4, 8] - [4, 0, 0, { max: 17, min: 9 }, 0], // no AM cycleIdx in column is valid → no snap (returns input) + [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(findNearestValidCycleIdx(raw, amPm, limit, interval)).toBe(expected); + expect(findNearestValidHourSlot(raw, amPm, limit, interval)).toBe(expected); }); - it("never returns a cycleIdx outside the rendered set when called with a valid raw", () => { - // interval=2: rendered cycle indices are {0,2,4,6,8,10}. The raw cycleIdx in real + 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 = findNearestValidCycleIdx(raw, 0, limit, 2); + const result = findNearestValidHourSlot(raw, 0, limit, 2); expect(renderedSet.includes(result)).toBe(true); } }); diff --git a/src/utils/separateAmPmHour.ts b/src/utils/separateAmPmHour.ts index b212988..2508185 100644 --- a/src/utils/separateAmPmHour.ts +++ b/src/utils/separateAmPmHour.ts @@ -1,17 +1,18 @@ /** - * Helpers for the `separateAmPmPicker` representation. The hour column tracks a - * 0–11 cycle index (where 0 is the noon/midnight "12" slot) and the AM/PM column - * tracks 0 (AM) or 1 (PM). The public API always exposes 24-hour values. + * 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; cycleIdx: number } => { - if (hour24 === 0) return { amPm: 0, cycleIdx: 0 }; - if (hour24 === 12) return { amPm: 1, cycleIdx: 0 }; - if (hour24 < 12) return { amPm: 0, cycleIdx: hour24 }; - return { amPm: 1, cycleIdx: hour24 - 12 }; +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 = (cycleIdx: number, amPm: number): number => { - if (cycleIdx === 0) return amPm === 1 ? 12 : 0; - return cycleIdx + (amPm === 1 ? 12 : 0); +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 index d2b78d0..aa9831d 100644 --- a/src/utils/snapSeparateAmPmHour.ts +++ b/src/utils/snapSeparateAmPmHour.ts @@ -11,34 +11,35 @@ const resolveLimit = (limit: Limit | undefined): { max: number; min: number } | }; /** - * Returns the cycleIdx in the rendered hour column nearest to `rawCycleIdx` such that - * `(cycleIdx, 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, `rawCycleIdx` is returned unchanged. + * 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 findNearestValidCycleIdx = ( - rawCycleIdx: number, +export const findNearestValidHourSlot = ( + rawHourSlot: number, currentAmPm: number, hourLimit: Limit | undefined, interval = 1 ): number => { const resolved = resolveLimit(hourLimit); - if (!resolved) return rawCycleIdx; + if (!resolved) return rawHourSlot; - if (isWithinLimit(combineToHour24(rawCycleIdx, currentAmPm), resolved.min, resolved.max)) { - return rawCycleIdx; + 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 - rawCycleIdx); + const distance = Math.abs(i - rawHourSlot); if (distance < bestDistance) { bestDistance = distance; best = i; } } - return best ?? rawCycleIdx; + return best ?? rawHourSlot; };