Skip to content

Commit c79aa15

Browse files
committed
feat: add timepicker component
Signed-off-by: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com>
1 parent 0402aa1 commit c79aa15

File tree

6 files changed

+1119
-0
lines changed

6 files changed

+1119
-0
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as TimePicker } from "./timePicker";
2+
export * from "./types";
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { useState } from "react";
3+
4+
import TimePicker from "./timePicker";
5+
6+
export default {
7+
title: "Components/TimePicker",
8+
component: TimePicker,
9+
argTypes: {
10+
value: {
11+
control: "text",
12+
description: "The time value (HH:MM format)",
13+
},
14+
interval: {
15+
control: "number",
16+
description: "Time interval in minutes for generated options",
17+
},
18+
variant: {
19+
control: {
20+
type: "select",
21+
options: ["outline", "subtle"],
22+
},
23+
description: "Visual variant of the input",
24+
},
25+
allowCustom: {
26+
control: "boolean",
27+
description: "Allow custom time input",
28+
},
29+
autoClose: {
30+
control: "boolean",
31+
description: "Auto close on selection",
32+
},
33+
use12Hour: {
34+
control: "boolean",
35+
description: "Use 12-hour format",
36+
},
37+
disabled: {
38+
control: "boolean",
39+
description: "Disable the input",
40+
},
41+
placement: {
42+
control: "select",
43+
options: [
44+
"bottom-start",
45+
"bottom-end",
46+
"top-start",
47+
"top-end",
48+
"right-start",
49+
"right-end",
50+
"left-start",
51+
"left-end",
52+
],
53+
description: "Popover placement",
54+
},
55+
scrollMode: {
56+
control: {
57+
type: "select",
58+
options: ["center", "start", "nearest"],
59+
},
60+
description: "Scroll behavior when opening",
61+
},
62+
minTime: {
63+
control: "text",
64+
description: "Minimum time (HH:MM)",
65+
},
66+
maxTime: {
67+
control: "text",
68+
description: "Maximum time (HH:MM)",
69+
},
70+
placeholder: {
71+
control: "text",
72+
description: "Placeholder text",
73+
},
74+
prefix: {
75+
control: false,
76+
description: "Prefix element",
77+
},
78+
suffix: {
79+
control: false,
80+
description: "Suffix element",
81+
},
82+
options: {
83+
control: "object",
84+
description: "Custom time options",
85+
},
86+
},
87+
parameters: {
88+
docs: {
89+
source: {
90+
type: "dynamic",
91+
},
92+
},
93+
layout: "centered",
94+
},
95+
tags: ["autodocs"],
96+
} as Meta<typeof TimePicker>;
97+
98+
type Story = StoryObj<typeof TimePicker>;
99+
100+
export const Basic: Story = {
101+
render: (args) => {
102+
const [value, setValue] = useState("");
103+
104+
return (
105+
<div className="p-2 space-y-2">
106+
<TimePicker {...args} value={value} onChange={setValue} />
107+
<div className="text-xs text-ink-gray-6">Value: {value || "—"}</div>
108+
</div>
109+
);
110+
},
111+
args: {
112+
placeholder: "Select time",
113+
interval: 15,
114+
allowCustom: true,
115+
autoClose: true,
116+
use12Hour: true,
117+
variant: "subtle",
118+
placement: "bottom-start",
119+
scrollMode: "center",
120+
},
121+
};
122+
123+
export const TwentyFourHourFormat: Story = {
124+
render: (args) => {
125+
const [value, setValue] = useState("13:30");
126+
127+
return (
128+
<div className="p-2 space-y-2">
129+
<TimePicker {...args} value={value} onChange={setValue} />
130+
<div className="text-xs text-ink-gray-6">Value: {value}</div>
131+
</div>
132+
);
133+
},
134+
name: "24 Hour Format",
135+
args: {
136+
use12Hour: false,
137+
interval: 15,
138+
allowCustom: true,
139+
autoClose: true,
140+
variant: "subtle",
141+
placement: "bottom-start",
142+
scrollMode: "center",
143+
},
144+
};
145+
146+
export const CustomOptions: Story = {
147+
render: (args) => {
148+
const [value, setValue] = useState("09:00");
149+
150+
return (
151+
<div className="p-2 space-y-2">
152+
<TimePicker {...args} value={value} onChange={setValue} />
153+
<div className="text-xs text-ink-gray-6">Value: {value}</div>
154+
</div>
155+
);
156+
},
157+
name: "Custom Options (no interval generation)",
158+
args: {
159+
allowCustom: false,
160+
autoClose: true,
161+
use12Hour: true,
162+
variant: "subtle",
163+
placement: "bottom-start",
164+
scrollMode: "center",
165+
options: [
166+
{ value: "08:00" },
167+
{ value: "09:00" },
168+
{ value: "09:30" },
169+
{ value: "10:00" },
170+
{ value: "11:15" },
171+
{ value: "13:45" },
172+
],
173+
},
174+
};
175+
176+
export const MinMaxRange: Story = {
177+
render: (args) => {
178+
const [value, setValue] = useState("08:00");
179+
180+
return (
181+
<div className="p-2 space-y-2">
182+
<TimePicker {...args} value={value} onChange={setValue} />
183+
<div className="text-xs text-ink-gray-6">Value: {value}</div>
184+
</div>
185+
);
186+
},
187+
args: {
188+
minTime: "08:00",
189+
maxTime: "12:00",
190+
interval: 15,
191+
allowCustom: true,
192+
autoClose: true,
193+
use12Hour: true,
194+
variant: "subtle",
195+
placement: "bottom-start",
196+
scrollMode: "center",
197+
},
198+
};
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* External dependencies.
3+
*/
4+
import React from "react";
5+
import clsx from "clsx";
6+
7+
/**
8+
* Internal dependencies.
9+
*/
10+
import Popover from "../popover/popover";
11+
import TextInput from "../textInput/textInput";
12+
import FeatherIcon from "../featherIcon";
13+
import type { TimePickerProps, TimePickerOption } from "./types";
14+
import { useTimePicker } from "./useTimePicker";
15+
16+
const TimePicker: React.FC<TimePickerProps> = ({
17+
value = "",
18+
onChange,
19+
onInputInvalid,
20+
onInvalidChange,
21+
onOpen,
22+
onClose,
23+
interval = 15,
24+
options = [],
25+
placement = "bottom-start",
26+
placeholder = "Select time",
27+
variant = "subtle",
28+
allowCustom = true,
29+
autoClose = true,
30+
use12Hour = true,
31+
disabled = false,
32+
scrollMode = "center",
33+
minTime = "",
34+
maxTime = "",
35+
prefix,
36+
suffix,
37+
}) => {
38+
const {
39+
showOptions,
40+
setShowOptions,
41+
displayValue,
42+
displayedOptions,
43+
isTyping,
44+
selectedAndNearest,
45+
highlightIndex,
46+
panelRef,
47+
inputRef,
48+
handleArrowDown,
49+
handleArrowUp,
50+
handleEnter,
51+
handleClickInput,
52+
handleFocus,
53+
handleBlur,
54+
handleEscape,
55+
handleDisplayValueChange,
56+
handleMouseEnter,
57+
select,
58+
optionId,
59+
} = useTimePicker({
60+
value,
61+
onChange,
62+
onInputInvalid,
63+
onInvalidChange,
64+
onOpen,
65+
onClose,
66+
interval,
67+
options,
68+
allowCustom,
69+
autoClose,
70+
use12Hour,
71+
scrollMode,
72+
minTime,
73+
maxTime,
74+
});
75+
76+
const getButtonClasses = (opt: TimePickerOption, idx: number): string => {
77+
if (idx === highlightIndex) return "bg-surface-gray-3 text-ink-gray-8";
78+
const { selected, nearest } = selectedAndNearest;
79+
if (isTyping && !selected) {
80+
if (nearest && nearest.value === opt.value)
81+
return "text-ink-gray-7 italic bg-surface-gray-2";
82+
return "text-ink-gray-6 hover:bg-surface-gray-2 hover:text-ink-gray-8";
83+
}
84+
if (selected && selected.value === opt.value)
85+
return "bg-surface-gray-3 text-ink-gray-8";
86+
if (nearest && nearest.value === opt.value)
87+
return "text-ink-gray-7 italic bg-surface-gray-2";
88+
return "text-ink-gray-6 hover:bg-surface-gray-2 hover:text-ink-gray-8";
89+
};
90+
91+
return (
92+
<Popover
93+
show={showOptions && !disabled}
94+
onUpdateShow={(show) => !disabled && setShowOptions(show)}
95+
placement={placement}
96+
target={({ togglePopover, isOpen }) => (
97+
<TextInput
98+
ref={inputRef}
99+
value={displayValue}
100+
onChange={handleDisplayValueChange}
101+
variant={variant}
102+
type="text"
103+
placeholder={placeholder}
104+
disabled={disabled}
105+
readOnly={!allowCustom}
106+
onFocus={handleFocus}
107+
onClick={() => !disabled && handleClickInput(isOpen, togglePopover)}
108+
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
109+
if (e.key === "Enter") handleEnter(e);
110+
else if (e.key === "Escape") handleEscape(e);
111+
else if (e.key === "ArrowDown")
112+
handleArrowDown(e, togglePopover, isOpen);
113+
else if (e.key === "ArrowUp")
114+
handleArrowUp(e, togglePopover, isOpen);
115+
}}
116+
onBlur={handleBlur}
117+
prefix={prefix}
118+
suffix={
119+
suffix
120+
? suffix
121+
: () => (
122+
<div
123+
onClick={(e) => {
124+
e.stopPropagation();
125+
}}
126+
>
127+
<FeatherIcon
128+
name="chevron-down"
129+
className="h-4 w-4 cursor-pointer"
130+
onMouseDown={(e) => {
131+
e.preventDefault();
132+
togglePopover();
133+
}}
134+
/>
135+
</div>
136+
)
137+
}
138+
/>
139+
)}
140+
body={({ isOpen }) => (
141+
<div
142+
ref={panelRef}
143+
style={{ display: isOpen ? "block" : "none" }}
144+
className="mt-2 max-h-48 w-44 overflow-y-auto rounded-lg bg-surface-modal p-1 text-base shadow-2xl ring-1 ring-black/5 focus:outline-none"
145+
role="listbox"
146+
aria-activedescendant={
147+
highlightIndex >= 0 ? optionId(highlightIndex) : undefined
148+
}
149+
>
150+
{displayedOptions.map((opt, idx) => (
151+
<button
152+
key={opt.value}
153+
data-value={opt.value}
154+
data-index={idx}
155+
type="button"
156+
className={clsx(
157+
"group flex h-7 w-full items-center rounded px-2 text-left",
158+
getButtonClasses(opt, idx)
159+
)}
160+
onClick={() => select(opt.value)}
161+
onMouseEnter={() => handleMouseEnter(idx)}
162+
role="option"
163+
id={optionId(idx)}
164+
aria-selected={opt.value === value}
165+
>
166+
<span className="truncate">{opt.label}</span>
167+
</button>
168+
))}
169+
</div>
170+
)}
171+
/>
172+
);
173+
};
174+
175+
export default TimePicker;

0 commit comments

Comments
 (0)