Skip to content

Commit c835336

Browse files
authored
feat(blade): added timepicker component (#2934)
* feat(timepicker): added timepicker component * fix: build error * chore: updated react-aria to latest version * chore: updated storybook example * chore: removed redundant code * chore: resolved comments * fix: lint error * fix: lint error * fix: lint error * fix: lint error * chore: stying update * feat(timepicker): added component in knowledge base * Create tiny-mails-sing.md * fix: added missed prop * chore: removed unused files * chore: code clean up * fixed: uncontrolled state * fix: failing calculation for minute step * chore: added classname for component * chore: updated componentStatusData * chore: resolved comments * fix: prevent bottomsheet closing when footer action present on click of spin wheel * chore: made label not mandatory * chore: resolved comments * fix: uncontrolled state * fix: moved day period to uppercase * chore: optimised and resolved comments * chore: code clean up * fix: ios scroll * chore: fix iOS scrolling * chore: resolved comments
1 parent 16576ed commit c835336

File tree

27 files changed

+3158
-36
lines changed

27 files changed

+3158
-36
lines changed

.changeset/tiny-mails-sing.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@razorpay/blade": minor
3+
"@razorpay/blade-mcp": minor
4+
---
5+
6+
feat(timepicker): added timepicker component
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
# TimePicker
2+
3+
## Description
4+
5+
The TimePicker component is a comprehensive time selection input that supports both 12-hour and 24-hour formats with configurable minute step intervals. It features an accessible segmented input field where users can directly type or use dropdown/bottom sheet selection with scrollable spin wheels. The component provides responsive layouts with desktop dropdown and mobile bottom sheet, includes validation states, and supports both controlled and uncontrolled usage patterns.
6+
7+
## Important Constraints
8+
9+
- `timeFormat` only accepts `'12h'` or `'24h'` values
10+
- `minuteStep` only accepts `1`, `5`, `15`, or `30` as valid values
11+
- `size` only accepts `'medium'` or `'large'` values
12+
- `labelPosition` only accepts `'top'` or `'left'` values
13+
- When using controlled mode, both `value` and `onChange` props must be provided
14+
- `onApply` callback is only triggered when `showFooterActions` is `true` and user clicks Apply button
15+
- Component requires a valid Date object or null for `value` and `defaultValue` props
16+
- `validationState` only accepts `'error'`, `'success'`, or `'none'` values
17+
18+
## TypeScript Types
19+
20+
These types define the props that the TimePicker component and its related components accept. Use these for proper TypeScript integration and to understand available configuration options.
21+
22+
```typescript
23+
/**
24+
* Time format types supported by TimePicker
25+
* Both 12h and 24h formats are supported using React Aria.
26+
*/
27+
type TimeFormat = '12h' | '24h';
28+
29+
/**
30+
* Minute step intervals supported by TimePicker
31+
*/
32+
type MinuteStep = 1 | 5 | 15 | 30;
33+
34+
/**
35+
* Value object returned by TimePicker onChange and onApply callbacks
36+
* Designed for future extensibility while maintaining backwards compatibility
37+
*/
38+
type TimePickerValue = {
39+
/**
40+
* The selected time as a Date object
41+
*/
42+
value: Date | null;
43+
};
44+
45+
/**
46+
* Individual time component identifiers
47+
*/
48+
type TimePart = 'hour' | 'minute' | 'period';
49+
50+
type TimePickerCommonInputProps = {
51+
inputRef?: React.Ref<any>;
52+
referenceProps?: any;
53+
} & Pick<
54+
BaseInputProps,
55+
| 'labelPosition'
56+
| 'size'
57+
| 'isRequired'
58+
| 'necessityIndicator'
59+
| 'autoFocus'
60+
| 'isDisabled'
61+
| 'accessibilityLabel'
62+
| 'name'
63+
| 'placeholder'
64+
| 'label'
65+
| 'onFocus'
66+
| 'onBlur'
67+
| 'labelSuffix'
68+
| 'labelTrailing'
69+
> &
70+
FormInputValidationProps;
71+
72+
/**
73+
* Main TimePicker component props
74+
* Combines input functionality with time selection capabilities
75+
*/
76+
type TimePickerProps = Omit<
77+
TimePickerInputProps,
78+
'inputRef' | 'refrenceProps' | 'successText' | 'errorText' | 'helpText' | 'time' | 'onChange'
79+
> &
80+
Omit<TimePickerSelectorProps, 'isOpen' | 'defaultIsOpen' | 'onOpenChange' | 'time'> & {
81+
/**
82+
* Current time value as Date object (for controlled usage)
83+
*/
84+
value?: Date | null;
85+
86+
/**
87+
* Callback fired when time value changes
88+
* @param timeValue - Object containing the selected time
89+
*/
90+
onChange?: (timeValue: TimePickerValue) => void;
91+
92+
/**
93+
* Label for the time input
94+
*/
95+
label?: string;
96+
97+
/**
98+
* Help text to guide the user
99+
*/
100+
helpText?: string;
101+
102+
/**
103+
* Error text to show validation errors
104+
*/
105+
errorText?: string;
106+
107+
/**
108+
* Success text to show validation success
109+
*/
110+
successText?: string;
111+
112+
/**
113+
* Controls dropdown open state (for controlled usage)
114+
* @default false
115+
*/
116+
isOpen?: boolean;
117+
118+
/**
119+
* Default open state (for uncontrolled usage)
120+
* @default false
121+
*/
122+
defaultIsOpen?: boolean;
123+
124+
/**
125+
* Callback fired when dropdown open state changes
126+
* @param state - Object containing the new open state
127+
*/
128+
onOpenChange?: (state: { isOpen: boolean }) => void;
129+
130+
/**
131+
* Test ID for testing purposes
132+
*/
133+
testID?: string;
134+
135+
/**
136+
* Accessibility label for screen readers
137+
* When not provided, falls back to label prop
138+
*/
139+
accessibilityLabel?: string;
140+
};
141+
142+
type TimePickerSelectorProps = {
143+
/**
144+
* Current time value as Date object (for controlled usage)
145+
*/
146+
time?: Date | null;
147+
148+
/**
149+
* Default time value as Date object (for uncontrolled usage)
150+
*/
151+
defaultValue?: Date;
152+
153+
/**
154+
* Callback fired when time value changes during selection
155+
* @param timeValue - Object containing the selected time and future extensible properties
156+
*/
157+
onChange?: (timeValue: TimePickerValue) => void;
158+
159+
/**
160+
* Time format for display and interaction
161+
* @default "12h"
162+
*
163+
* Both 12h and 24h formats are supported using React Aria.
164+
*/
165+
timeFormat?: TimeFormat;
166+
167+
/**
168+
* Controls dropdown open state (for controlled usage)
169+
* @default false
170+
*/
171+
isOpen?: boolean;
172+
173+
/**
174+
* Default open state (for uncontrolled usage)
175+
* @default false
176+
*/
177+
defaultIsOpen?: boolean;
178+
179+
/**
180+
* Step interval for minutes selection
181+
* @default 1
182+
* @example 15 // allows 00, 15, 30, 45 minutes only
183+
*/
184+
minuteStep?: MinuteStep;
185+
186+
/**
187+
* Callback fired when dropdown open state changes
188+
* @param state - Object containing the new open state
189+
*/
190+
onOpenChange?: (state: { isOpen: boolean }) => void;
191+
192+
/**
193+
* Whether to show the apply/cancel buttons in the dropdown
194+
* @default true
195+
*
196+
* When true:
197+
* - Shows Apply/Cancel buttons for explicit confirmation
198+
* - User must click Apply to confirm selection
199+
* - Better for complex time selections
200+
*
201+
* When false:
202+
* - On blur, selected time will automatically apply and close the dropdown
203+
* - Pressing Enter immediately applies the current selection and closes
204+
* - More streamlined interaction experience
205+
*/
206+
showFooterActions?: boolean;
207+
208+
/**
209+
* Callback fired when user applies time selection
210+
* Only called when showFooterActions is true and user clicks Apply
211+
* @param timeValue - Object containing the confirmed time value
212+
*/
213+
onApply?: (timeValue: TimePickerValue) => void;
214+
215+
/**
216+
* To set the controlled value of the time picker
217+
*/
218+
setControlledValue?: (time: Date | null) => void;
219+
220+
size?: 'medium' | 'large';
221+
};
222+
223+
/**
224+
* Props for individual time column components (Hours, Minutes, Period)
225+
*/
226+
type TimeColumnProps = {
227+
values: string[];
228+
selectedValue: string;
229+
onValueChange: (value: string) => void;
230+
};
231+
232+
/**
233+
* Props for time picker footer actions (Apply/Cancel buttons)
234+
*/
235+
type TimePickerFooterProps = {
236+
onApply: () => void;
237+
onCancel: () => void;
238+
isApplyDisabled?: boolean;
239+
};
240+
241+
type TimeSegmentProps = {
242+
segment: DateSegment;
243+
state: TimeFieldState;
244+
isDisabled?: boolean;
245+
};
246+
```
247+
248+
## Example
249+
250+
### TimePicker Usage
251+
252+
```tsx
253+
import React, { useState, useMemo } from 'react';
254+
import { TimePicker } from '@razorpay/blade/components';
255+
import { Box, Text, Button } from '@razorpay/blade/components';
256+
import { Tooltip, TooltipInteractiveWrapper } from '@razorpay/blade/components';
257+
import { Link } from '@razorpay/blade/components';
258+
import { InfoIcon } from '@razorpay/blade/icons';
259+
260+
function TimePickerExample() {
261+
const [basicTime, setBasicTime] = useState<Date | null>(null);
262+
const [advancedTime, setAdvancedTime] = useState<Date | null>(null);
263+
const [isOpen, setIsOpen] = useState(false);
264+
const [hasError, setHasError] = useState(false);
265+
266+
// Validation for business hours (9 AM - 6 PM)
267+
const validateTime = (time: Date | null) => {
268+
if (!time) return;
269+
const hour = time.getHours();
270+
setHasError(hour < 9 || hour >= 18);
271+
};
272+
273+
return (
274+
<Box display="flex" flexDirection="column" gap="spacing.5">
275+
{/* Basic TimePicker - shows common usage */}
276+
<TimePicker
277+
label="Meeting Time"
278+
timeFormat="12h"
279+
size="medium"
280+
value={basicTime}
281+
onChange={({ value }) => setBasicTime(value)}
282+
showFooterActions={false}
283+
minuteStep={15}
284+
helpText="Select your meeting time (15-minute intervals)"
285+
placeholder="Select time"
286+
accessibilityLabel="Select meeting time"
287+
/>
288+
289+
{/* Advanced TimePicker - shows all advanced features */}
290+
<TimePicker
291+
label="Business Hours Appointment"
292+
labelPosition="top"
293+
labelSuffix={
294+
<Tooltip content="Must be during business hours (9 AM - 6 PM)" placement="right">
295+
<TooltipInteractiveWrapper display="flex">
296+
<InfoIcon size="small" color="surface.icon.gray.muted" />
297+
</TooltipInteractiveWrapper>
298+
</Tooltip>
299+
}
300+
labelTrailing={<Link size="small">Time zones</Link>}
301+
timeFormat="24h"
302+
size="large"
303+
value={advancedTime}
304+
defaultValue={new Date('2024-01-01T14:30:00')}
305+
onChange={({ value }) => {
306+
setAdvancedTime(value);
307+
validateTime(value);
308+
}}
309+
onApply={({ value }) => console.log('Applied:', value)}
310+
isOpen={isOpen}
311+
onOpenChange={({ isOpen }) => setIsOpen(isOpen)}
312+
isRequired
313+
necessityIndicator="required"
314+
validationState={hasError ? 'error' : advancedTime ? 'success' : 'none'}
315+
errorText={hasError ? 'Please select time during business hours (9 AM - 6 PM)' : undefined}
316+
successText={!hasError && advancedTime ? 'Valid appointment time' : undefined}
317+
showFooterActions={true}
318+
minuteStep={30}
319+
isDisabled={false}
320+
autoFocus={false}
321+
name="appointment-time"
322+
testID="advanced-timepicker"
323+
accessibilityLabel="Select appointment time during business hours"
324+
/>
325+
326+
{/* Control buttons to demonstrate programmatic usage */}
327+
<Box display="flex" gap="spacing.3">
328+
<Button size="small" onClick={() => setAdvancedTime(new Date())}>
329+
Set Current Time
330+
</Button>
331+
<Button size="small" variant="secondary" onClick={() => setIsOpen(!isOpen)}>
332+
Toggle Dropdown
333+
</Button>
334+
</Box>
335+
</Box>
336+
);
337+
}
338+
```

packages/blade/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@
147147
"@mantine/core": "6.0.21",
148148
"@mantine/dates": "6.0.21",
149149
"@mantine/hooks": "6.0.21",
150+
"@react-aria/i18n": "3.12.2",
151+
"@react-aria/datepicker": "3.15.1",
152+
"@react-stately/datepicker": "3.15.1",
153+
"@internationalized/date": "3.9.0",
150154
"dayjs": "1.11.10",
151155
"react-window": "1.8.11",
152156
"react-zoom-pan-pinch": "3.7.0",

packages/blade/src/components/BottomSheet/BottomSheet.web.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,17 @@ const _BottomSheet = ({
250250
lastOffset: [_, lastOffsetY],
251251
down,
252252
dragging,
253+
event,
253254
args: [{ isContentDragging = false } = {}] = [],
254255
}) => {
256+
// Check if the touch started on a scrollable element (e.g., SpinWheel in TimePicker)
257+
// This prevents BottomSheet drag gestures from interfering with internal scrolling
258+
const touchTarget = event?.target as Element | undefined;
259+
const isScrollableContent = touchTarget?.closest('[data-allow-scroll]');
260+
261+
if (isScrollableContent) {
262+
return;
263+
}
255264
setIsDragging(Boolean(dragging));
256265
// lastOffsetY is the previous position user stopped dragging the sheet
257266
// movementY is the drag amount from the bottom of the screen, so as you drag up the movementY goes into negatives
@@ -349,7 +358,13 @@ const _BottomSheet = ({
349358

350359
const preventScrolling = (e: Event) => {
351360
if (preventScrollingRef?.current) {
352-
e.preventDefault();
361+
// Allow scrolling for components that explicitly need scroll functionality
362+
const target = e.target as Element;
363+
const isAllowedComponent = target.closest('[data-allow-scroll]');
364+
365+
if (!isAllowedComponent) {
366+
e.preventDefault();
367+
}
353368
}
354369
};
355370

0 commit comments

Comments
 (0)