From 9fe7ca8b7d8e3dd290c250c4d3e4bc3d923c10eb Mon Sep 17 00:00:00 2001 From: Hoongeun Cho Date: Tue, 2 Aug 2022 18:09:49 +0900 Subject: [PATCH 1/2] [feature] timerange --- src/components/Clock.tsx | 6 +- src/components/Numbers.tsx | 22 +- src/components/TimeDropdown.tsx | 14 +- src/components/TimeKeeperContainer.tsx | 3 + src/helpers/__tests__/compose-time.test.ts | 4 +- src/helpers/__tests__/disable-time.test.ts | 223 ++++++++++++++++++++- src/helpers/disable-time.ts | 78 ++++++- src/helpers/time.ts | 10 +- src/hooks/useStateContext.tsx | 45 +++-- 9 files changed, 347 insertions(+), 58 deletions(-) diff --git a/src/components/Clock.tsx b/src/components/Clock.tsx index 9565d39..8017a08 100644 --- a/src/components/Clock.tsx +++ b/src/components/Clock.tsx @@ -31,7 +31,7 @@ interface Props { export default function ClockWrapper({ clockEl }: Props) { const firstRun = useRef(true) const { hour24Mode } = useConfig() - const { mode, time, meridiem, disabledTimeRangeValidator } = useTimekeeperState() + const { mode, time, meridiem, timeRangeValidator } = useTimekeeperState() const transitions = useTransition(mode, { unique: true, @@ -63,7 +63,7 @@ export default function ClockWrapper({ clockEl }: Props) { isMinuteMode(currentMode) ? ( ) : ( @@ -71,7 +71,7 @@ export default function ClockWrapper({ clockEl }: Props) { anim={anim} mode={currentMode as MODE.HOURS_12 | MODE.HOURS_24} hour24Mode={hour24Mode} - disabledTimeRangeValidator={disabledTimeRangeValidator} + timeRangeValidator={timeRangeValidator} meridiem={meridiem} /> ), diff --git a/src/components/Numbers.tsx b/src/components/Numbers.tsx index 435ff92..c45d79a 100644 --- a/src/components/Numbers.tsx +++ b/src/components/Numbers.tsx @@ -1,13 +1,13 @@ import { memo, useMemo } from 'react' import { animated, SpringValue } from 'react-spring' -import DisabledTimeRange from '../helpers/disable-time' +import { TimeRangeValidator } from '../helpers/disable-time' import { MINUTES, CLOCK_VALUES, MODE, MERIDIEM } from '../helpers/constants' import { transform } from '../helpers/math' import { numbersStyle, numbersWrapperStyle } from './styles/numbers' interface CommonProps { - disabledTimeRangeValidator: DisabledTimeRange | null + timeRangeValidator: TimeRangeValidator | null anim: { opacity: SpringValue translate: SpringValue @@ -33,7 +33,7 @@ function Hours({ anim, mode, hour24Mode, - disabledTimeRangeValidator, + timeRangeValidator, meridiem, }: HourProps) { const { opacity, translate: translateOuter, translateInner } = anim @@ -55,17 +55,17 @@ function Hours({ numbersOuter: numbersOuter.map((value, i) => ({ value, enabled: - disabledTimeRangeValidator?.validateHour(normalizeOuterIndex(i)) ?? + timeRangeValidator?.validateHour(normalizeOuterIndex(i)) ?? true, })), numbersInner: numbersInner?.map((value, i) => ({ value, enabled: - disabledTimeRangeValidator?.validateHour(normalizeInnerIndex(i)) ?? + timeRangeValidator?.validateHour(normalizeInnerIndex(i)) ?? true, })), } - }, [mode, meridiem, disabledTimeRangeValidator]) + }, [mode, meridiem, timeRangeValidator]) return ( { prev.mode === next.mode && prev.hour24Mode === next.hour24Mode && prev.meridiem === next.meridiem && - prev.disabledTimeRangeValidator === next.disabledTimeRangeValidator + prev.timeRangeValidator === next.timeRangeValidator ) }) -function Minutes({ anim, hour, disabledTimeRangeValidator }: MinuteProps) { +function Minutes({ anim, hour, timeRangeValidator }: MinuteProps) { const { opacity, translate } = anim const minutes = useMemo(() => { return MINUTES.map(value => ({ value, enabled: - disabledTimeRangeValidator?.validateMinute(hour, parseInt(value, 10)) ?? + timeRangeValidator?.validateMinute(hour, parseInt(value, 10)) ?? true, })) - }, [disabledTimeRangeValidator, hour]) + }, [timeRangeValidator, hour]) return ( { return ( - prev.disabledTimeRangeValidator === next.disabledTimeRangeValidator && + prev.timeRangeValidator === next.timeRangeValidator && prev.hour === next.hour ) }) diff --git a/src/components/TimeDropdown.tsx b/src/components/TimeDropdown.tsx index 2d2a9eb..8456e32 100644 --- a/src/components/TimeDropdown.tsx +++ b/src/components/TimeDropdown.tsx @@ -18,7 +18,7 @@ type ElementLiRef = MutableRefObject export default function TimeDropdown({ close }: Props) { const { hour24Mode } = useConfig() - const { updateTimeValue, mode, time, meridiem, disabledTimeRangeValidator } = + const { updateTimeValue, mode, time, meridiem, timeRangeValidator } = useTimekeeperState() const container: ElementRef = useRef(null) @@ -28,31 +28,31 @@ export default function TimeDropdown({ close }: Props) { const o = CLOCK_VALUES[mode].dropdown let validator: (value: string, i: number) => boolean = () => true - if (disabledTimeRangeValidator) { + if (timeRangeValidator) { if (mode === MODE.HOURS_12) { if (meridiem === 'am') { validator = (_, i) => - disabledTimeRangeValidator.validateHour((i + 1) % 12) + timeRangeValidator.validateHour((i + 1) % 12) } else { validator = (_, i) => { // account for last number (12) which should be first (noon, 1pm, ...) in 24h format const num = i === 11 ? 12 : i + 13 - return disabledTimeRangeValidator.validateHour(num) + return timeRangeValidator.validateHour(num) } } } else if (mode === MODE.HOURS_24) { validator = (_, i) => - disabledTimeRangeValidator.validateHour((i + 1) % 24) + timeRangeValidator.validateHour((i + 1) % 24) } else if (mode === MODE.MINUTES) { validator = v => - disabledTimeRangeValidator.validateMinute(time.hour, parseInt(v, 10)) + timeRangeValidator.validateMinute(time.hour, parseInt(v, 10)) } } return o.map((value, i) => ({ value, enabled: validator(value, i), })) - }, [mode, disabledTimeRangeValidator, meridiem, time.hour]) + }, [mode, timeRangeValidator, meridiem, time.hour]) const selected = getNormalizedTimeValue(mode, time).toString() diff --git a/src/components/TimeKeeperContainer.tsx b/src/components/TimeKeeperContainer.tsx index 7056cef..96715d4 100644 --- a/src/components/TimeKeeperContainer.tsx +++ b/src/components/TimeKeeperContainer.tsx @@ -6,6 +6,7 @@ import { TimeInput, ChangeTimeFn } from '../helpers/types' export interface Props extends ConfigProps { time?: TimeInput onChange?: ChangeTimeFn + timeRange?: null | { from: string, to: string } disabledTimeRange?: null | { from: string; to: string } } @@ -21,6 +22,7 @@ export default function TimepickerWithConfig({ hour24Mode, onDoneClick, doneButton, + timeRange, disabledTimeRange, }: Props) { return ( @@ -37,6 +39,7 @@ export default function TimepickerWithConfig({ diff --git a/src/helpers/__tests__/compose-time.test.ts b/src/helpers/__tests__/compose-time.test.ts index 5b47e75..c2d5ab4 100644 --- a/src/helpers/__tests__/compose-time.test.ts +++ b/src/helpers/__tests__/compose-time.test.ts @@ -1,4 +1,4 @@ -import DisabledTimeRange from '../disable-time' +import { TimeOutRange } from '../disable-time' import { composeTime as compose } from '../time' describe('helpers/compose-time', () => { @@ -49,7 +49,7 @@ describe('helpers/compose-time', () => { }) it('supports disabled time ranges', () => { - const dsr = new DisabledTimeRange('6:20', '15:20') + const dsr = new TimeOutRange('6:20', '15:20') expect(compose(6, 20, dsr)).toHaveProperty('isValid', true) expect(compose(6, 21, dsr)).toHaveProperty('isValid', false) }) diff --git a/src/helpers/__tests__/disable-time.test.ts b/src/helpers/__tests__/disable-time.test.ts index 07b0b5b..1362d6f 100644 --- a/src/helpers/__tests__/disable-time.test.ts +++ b/src/helpers/__tests__/disable-time.test.ts @@ -1,4 +1,4 @@ -import DisabledTimeRange from '../disable-time' +import {TimeOutRange, TimeInRange} from '../disable-time' interface HourTestCase { name: string @@ -14,7 +14,7 @@ interface MinuteTestCase extends Omit { cases: [hour: number, minute: number, expected: boolean][] } -const hourTestCases: HourTestCase[] = [ +const outRangeHourTestCases: HourTestCase[] = [ { name: 'basic time range', from: '6:00', @@ -144,7 +144,7 @@ const hourTestCases: HourTestCase[] = [ }, ] -const MinuteTestCase: MinuteTestCase[] = [ +const outRangeMinuteTestCase: MinuteTestCase[] = [ { name: 'basic time range, on the hour', from: '6:00', @@ -193,10 +193,10 @@ const MinuteTestCase: MinuteTestCase[] = [ }, ] -describe('disabled time range - hours', () => { - hourTestCases.forEach(({ name, from, to, cases }) => { +describe('time out range - hours', () => { + outRangeHourTestCases.forEach(({ name, from, to, cases }) => { describe(`${name}: ${from} -> ${to}`, () => { - const dtr = new DisabledTimeRange(from, to) + const dtr = new TimeOutRange(from, to) cases.forEach(([hour, expected]) => { it(`hour: ${hour} -> ${expected}`, () => { expect(dtr.validateHour(hour)).toEqual(expected) @@ -206,10 +206,215 @@ describe('disabled time range - hours', () => { }) }) -describe('disabled time range - minutes', () => { - MinuteTestCase.forEach(({ name, from, to, cases }) => { +describe('time out range - minutes', () => { + outRangeMinuteTestCase.forEach(({ name, from, to, cases }) => { describe(`${name}: ${from} -> ${to}`, () => { - const dtr = new DisabledTimeRange(from, to) + const dtr = new TimeOutRange(from, to) + cases.forEach(([hour, minute, expected]) => { + it(`hour: ${hour}, minute: ${minute} -> ${expected}`, () => { + expect(dtr.validateMinute(hour, minute)).toEqual(expected) + }) + }) + }) + }) +}) + +const inRangeHourTestCases: HourTestCase[] = [ + { + name: 'basic time range', + from: '6:00', + to: '15:00', + cases: [ + [0, false], + [4, false], + [6, true], + [7, true], + [14, true], + [15, true], + [16, false], + ], + }, + { + name: 'basic time range with minutes', + from: '6:20', + to: '15:35', + cases: [ + [4, false], + [6, true], + [7, true], + [14, true], + [15, true], + [16, false], + ], + }, + { + name: 'overnight time range', + from: '15:00', + to: '6:00', + cases: [ + [4, true], + [6, true], + [7, false], + [14, false], + [15, true], + [16, true], + ], + }, + { + name: 'overnight time range with minutes', + from: '15:20', + to: '6:35', + cases: [ + [4, true], + [6, true], + [7, false], + [14, false], + [15, true], + [16, true], + ], + }, + { + name: 'same hour, regular range', + from: '6:20', + to: '6:45', + cases: [ + [2, false], + [5, false], + [6, true], + [7, false], + [10, false], + ], + }, + { + name: 'same hour, overnight', + from: '6:45', + to: '6:20', + cases: [ + [2, true], + [5, true], + [6, true], + [7, true], + [10, true], + ], + }, + { + name: 'same hour, on the hour', + from: '6:00', + to: '6:20', + cases: [ + [2, false], + [5, false], + [6, true], + [7, false], + [10, false], + ], + }, + { + name: 'midnight, regular range', + from: '0:20', + to: '6:20', + cases: [ + [0, true], + [1, true], + [5, true], + [6, true], + [7, false], + [10, false], + ], + }, + { + name: 'midnight, on the hour, regular range', + from: '0:00', + to: '6:20', + cases: [ + [0, true], + [1, true], + [5, true], + [6, true], + [7, false], + [10, false], + ], + }, + { + name: 'midnight, overnight', + from: '20:00', + to: '0:00', + cases: [ + [0, true], + [1, false], + [19, false], + [20, true], + [24, true], + ], + }, +] + +const InRangeMinuteTestCase: MinuteTestCase[] = [ + { + name: 'basic time range, on the hour', + from: '6:00', + to: '15:00', + cases: [ + [5, 59, false], + [6, 0, true], + [7, 5, true], + [15, 0, true], + [15, 5, false], + ], + }, + { + name: 'basic time range', + from: '6:20', + to: '15:35', + cases: [ + [5, 59, false], + [6, 19, false], + [6, 20, true], + [6, 21, true], + [7, 5, true], + [15, 0, true], + [15, 5, true], + [15, 34, true], + [15, 35, true], + [15, 36, false], + ], + }, + { + name: 'overnight time range', + from: '15:35', + to: '6:20', + cases: [ + [14, 34, false], + [15, 34, false], + [15, 35, true], + [15, 36, true], + [16, 19, true], + [5, 0, true], + [6, 0, true], + [6, 19, true], + [6, 20, true], + [6, 21, false], + ], + }, +] + +describe('time in range - hours', () => { + inRangeHourTestCases.forEach(({ name, from, to, cases }) => { + describe(`${name}: ${from} -> ${to}`, () => { + const dtr = new TimeInRange(from, to) + cases.forEach(([hour, expected]) => { + it(`hour: ${hour} -> ${expected}`, () => { + expect(dtr.validateHour(hour)).toEqual(expected) + }) + }) + }) + }) +}) + +describe('time in range - minutes', () => { + InRangeMinuteTestCase.forEach(({ name, from, to, cases }) => { + describe(`${name}: ${from} -> ${to}`, () => { + const dtr = new TimeInRange(from, to) cases.forEach(([hour, minute, expected]) => { it(`hour: ${hour}, minute: ${minute} -> ${expected}`, () => { expect(dtr.validateMinute(hour, minute)).toEqual(expected) diff --git a/src/helpers/disable-time.ts b/src/helpers/disable-time.ts index 27f7648..f0d231c 100644 --- a/src/helpers/disable-time.ts +++ b/src/helpers/disable-time.ts @@ -16,7 +16,47 @@ function parseTime(time: string) { } } -function generateHourValidator( +function generateInRangeHourValidator( + fromH: number, + fromM: number, + toH: number, + toM: number, +): (hour: number) => boolean { + const minH = fromH + const maxH = toH + const isSameHour = fromH === toH + + if (fromH < toH || (isSameHour && fromM < toM)) { + // regular range + return hour => hour >= minH && hour <= maxH + } + + // overnight range: fromH > toH || (isSameHour && fromM > toM) + return hour => hour >= minH || hour <= maxH +} + +function generateInRangeMinuteValidator( + fromH: number, + fromM: number, + toH: number, + toM: number, + hourValidator: (hour: number) => boolean, +): (hour: number, minute: number) => boolean { + return (h, m) => { + // if hour is invalid, all minutes should be invalid + if (!hourValidator(h)) { + return false + } + if (h === fromH) { + return m >= fromM + } else if (h === toH) { + return m <= toM + } + return true + } +} + +function generateOutRangeHourValidator( fromH: number, fromM: number, toH: number, @@ -35,7 +75,7 @@ function generateHourValidator( return hour => hour <= minH && hour >= maxH } -function generateMinuteValidator( +function generateOutRangeMinuteValidator( fromH: number, fromM: number, toH: number, @@ -56,7 +96,35 @@ function generateMinuteValidator( } } -export default class DisabledTimeRange { +export interface TimeRangeValidator { + validateHour(hour: number): boolean + validateMinute(hour: number, minute: number): boolean +} + +export class TimeInRange implements TimeRangeValidator { + constructor(from: string, to: string) { + const { hour: fromH, minute: fromM } = parseTime(from) + const { hour: toH, minute: toM } = parseTime(to) + + if (fromH === toH && fromM === toM) { + throw new Error('invalid date range - same time') + } + + this.validateHour = generateInRangeHourValidator(fromH, fromM, toH, toM) + this.validateMinute = generateInRangeMinuteValidator( + fromH, + fromM, + toH, + toM, + this.validateHour, + ) + } + + validateHour: (hour: number) => boolean + validateMinute: (hour: number, minute: number) => boolean +} + +export class TimeOutRange implements TimeRangeValidator { constructor(from: string, to: string) { const { hour: fromH, minute: fromM } = parseTime(from) const { hour: toH, minute: toM } = parseTime(to) @@ -65,8 +133,8 @@ export default class DisabledTimeRange { throw new Error('invalid date range - same time') } - this.validateHour = generateHourValidator(fromH, fromM, toH, toM) - this.validateMinute = generateMinuteValidator( + this.validateHour = generateOutRangeHourValidator(fromH, fromM, toH, toM) + this.validateMinute = generateOutRangeMinuteValidator( fromH, fromM, toH, diff --git a/src/helpers/time.ts b/src/helpers/time.ts index 21335e0..5846037 100644 --- a/src/helpers/time.ts +++ b/src/helpers/time.ts @@ -1,4 +1,4 @@ -import DisabledTimeRange from './disable-time' +import { TimeRangeValidator } from './disable-time' import { Time, TimeInput, TimeOutput } from './types' const TIME_PARSE_MERIDIEM = new RegExp(/^(\d{1,2}?):(\d{2}?)\s?(am|pm)$/i) @@ -91,7 +91,7 @@ export function parseMeridiem(time: TimeInput): string { export function composeTime( hour: number, minute: number, - disabledTimeRangeValidator: DisabledTimeRange | null, + timeRangeValidator: TimeRangeValidator | null, ): TimeOutput { const paddedMinute = ('0' + minute).slice(-2) const hour24 = hour === 24 ? 0 : hour @@ -105,10 +105,10 @@ export function composeTime( } let isValid = true - if (disabledTimeRangeValidator) { + if (timeRangeValidator) { if ( - !disabledTimeRangeValidator.validateHour(hour24) || - !disabledTimeRangeValidator.validateMinute(hour24, minute) + !timeRangeValidator.validateHour(hour24) || + !timeRangeValidator.validateMinute(hour24, minute) ) { isValid = false } diff --git a/src/hooks/useStateContext.tsx b/src/hooks/useStateContext.tsx index eb82a6f..8aadb53 100644 --- a/src/hooks/useStateContext.tsx +++ b/src/hooks/useStateContext.tsx @@ -15,12 +15,13 @@ import useConfig from './useConfigContext' import { isHourMode, isMinuteMode, isSameTime } from '../helpers/utils' import { TimeInput, ChangeTimeFn, Time, TimeOutput } from '../helpers/types' import { MODE, MERIDIEM } from '../helpers/constants' -import DisabledTimeRange from '../helpers/disable-time' +import {TimeInRange, TimeOutRange, TimeRangeValidator} from '../helpers/disable-time' interface Props { time?: TimeInput onChange?: ChangeTimeFn children: ReactElement + timeRange?: null | { from: string; to: string } disabledTimeRange?: null | { from: string; to: string } } @@ -46,7 +47,7 @@ interface StateContext { updateMeridiem: (meridiem: MERIDIEM) => void setMode: (mode: MODE) => void getComposedTime: () => TimeOutput - disabledTimeRangeValidator: DisabledTimeRange | null + timeRangeValidator: TimeRangeValidator | null meridiem: MERIDIEM } @@ -72,6 +73,7 @@ export function StateProvider({ onChange, time: parentTime, children, + timeRange, disabledTimeRange, }: Props) { const config = useConfig() @@ -99,14 +101,25 @@ export function StateProvider({ onDoneClickFn.current = config.onDoneClick }, [config.onDoneClick]) - const disabledTimeRangeValidator = useMemo(() => { - const from = disabledTimeRange?.from - const to = disabledTimeRange?.to - if (!from || !to) { - return null + const timeRangeValidator = useMemo(() => { + if (timeRange) { + const from = timeRange.from + const to = timeRange.to + if (!from || !to) { + return null + } + return new TimeInRange(from, to) + } + if (disabledTimeRange) { + const from = disabledTimeRange.from + const to = disabledTimeRange.to + if (!from || !to) { + return null + } + return new TimeOutRange(from, to) } - return new DisabledTimeRange(from, to) - }, [disabledTimeRange?.from, disabledTimeRange?.to]) + return null + }, [timeRange?.from, timeRange?.to, disabledTimeRange?.from, disabledTimeRange?.to]) // handle time update if parent changes useEffect(() => { @@ -126,8 +139,8 @@ export function StateProvider({ const getComposedTime = useCallback(() => { const time = refTime.current - return composeTime(time.hour, time.minute, disabledTimeRangeValidator) - }, [disabledTimeRangeValidator]) + return composeTime(time.hour, time.minute, timeRangeValidator) + }, [timeRangeValidator]) // debounced onChange function from parent const debounceUpdateParent = useMemo(() => { @@ -228,11 +241,11 @@ export function StateProvider({ } // if time is blocked off, dont update - if (disabledTimeRangeValidator) { + if (timeRangeValidator) { if ( - (isHourMode(mode) && !disabledTimeRangeValidator.validateHour(val)) || + (isHourMode(mode) && !timeRangeValidator.validateHour(val)) || (isMinuteMode(mode) && - !disabledTimeRangeValidator.validateMinute(time.hour, val)) + !timeRangeValidator.validateMinute(time.hour, val)) ) { return } @@ -248,7 +261,7 @@ export function StateProvider({ mode, meridiem, handleUpdateTimeSideEffects, - disabledTimeRangeValidator, + timeRangeValidator, updateTime, ], ) @@ -260,7 +273,7 @@ export function StateProvider({ updateMeridiem, setMode, getComposedTime, - disabledTimeRangeValidator, + timeRangeValidator, meridiem, } return {children} From f2e10607ffd2c857e15edc8c33a437aca5cc85b0 Mon Sep 17 00:00:00 2001 From: Hoongeun Cho Date: Tue, 2 Aug 2022 18:12:14 +0900 Subject: [PATCH 2/2] update examples --- docs/js/sections/api.tsx | 2 +- docs/js/sections/examples.tsx | 53 ++++++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docs/js/sections/api.tsx b/docs/js/sections/api.tsx index 070308a..ade6c3c 100644 --- a/docs/js/sections/api.tsx +++ b/docs/js/sections/api.tsx @@ -61,7 +61,7 @@ export default function Intro() { hour12: 4, minute: 55, meridiem: 'pm', - isValid: boolean, // requires \`disabledTimeRange\`, false if time selected is blocked off + isValid: boolean, // requires \`disabledTimeRange\` or \`timeRange\`, false if time selected is blocked off }`} diff --git a/docs/js/sections/examples.tsx b/docs/js/sections/examples.tsx index c4979e4..e08d89e 100644 --- a/docs/js/sections/examples.tsx +++ b/docs/js/sections/examples.tsx @@ -12,6 +12,8 @@ export default function Examples() { const [time4, setTime4] = useState('12:45pm') const [time5, setTime5] = useState('5:30') const [isValid5, setIsValid5] = useState(true) + const [time6, setTime6] = useState('7:00') + const [isValid6, setIsValid6] = useState(true) return (
@@ -39,7 +41,7 @@ import TimeKeeper from 'react-timekeeper'; function YourComponent(){ const [time, setTime] = useState('12:34pm') - + return (
- Time is {time5}, valid time: {isValid ? '✅' : '❌'} + Time is {time}, valid time: {isValid ? '✅' : '❌'} +
+ ) +}`} + + + {/* example 6 */} +
+ time range + +
+
+ { + setIsValid6(newTime.isValid) + setTime6(newTime.formatted12) + }} + timeRange={{ from: '6:20', to: '20:45' }} + /> +
+ + Time is {time6}, valid time: {isValid6 ? '✅' : '❌'} + +
+ + {`import React from 'react'; +import TimeKeeper from 'react-timekeeper'; + +function YourComponent(){ + const [time, setTime] = useState('12:34pm') + const [isValid, setIsValid] = useState(true) + + return ( +
+ { + setIsValid(newTime.isValid) + setTime(newTime.formatted12) + }} + timeRange={{ from: '6:20', to: '20:45' }} + /> + Time is {time}, valid time: {isValid ? '✅' : '❌'}
) }`}