Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/frappe-ui-react/src/components/timePicker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as TimePicker } from "./timePicker";
export * from "./types";
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";

import TimePicker from "./timePicker";

export default {
title: "Components/TimePicker",
component: TimePicker,
argTypes: {
value: {
control: "text",
description: "The time value (HH:MM format)",
},
interval: {
control: "number",
description: "Time interval in minutes for generated options",
},
variant: {
control: {
type: "select",
options: ["outline", "subtle"],
},
description: "Visual variant of the input",
},
allowCustom: {
control: "boolean",
description: "Allow custom time input",
},
autoClose: {
control: "boolean",
description: "Auto close on selection",
},
use12Hour: {
control: "boolean",
description: "Use 12-hour format",
},
disabled: {
control: "boolean",
description: "Disable the input",
},
placement: {
control: "select",
options: [
"bottom-start",
"bottom-end",
"top-start",
"top-end",
"right-start",
"right-end",
"left-start",
"left-end",
],
description: "Popover placement",
},
scrollMode: {
control: {
type: "select",
options: ["center", "start", "nearest"],
},
description: "Scroll behavior when opening",
},
minTime: {
control: "text",
description: "Minimum time (HH:MM)",
},
maxTime: {
control: "text",
description: "Maximum time (HH:MM)",
},
placeholder: {
control: "text",
description: "Placeholder text",
},
prefix: {
control: false,
description: "Prefix element",
},
suffix: {
control: false,
description: "Suffix element",
},
options: {
control: "object",
description: "Custom time options",
},
},
parameters: {
docs: {
source: {
type: "dynamic",
},
},
layout: "centered",
},
tags: ["autodocs"],
} as Meta<typeof TimePicker>;

type Story = StoryObj<typeof TimePicker>;

export const Basic: Story = {
render: (args) => {
const [value, setValue] = useState("");

return (
<div className="p-2 space-y-2">
<TimePicker {...args} value={value} onChange={setValue} />
<div className="text-xs text-ink-gray-6">Value: {value || "—"}</div>
</div>
);
},
args: {
placeholder: "Select time",
interval: 15,
allowCustom: true,
autoClose: true,
use12Hour: true,
variant: "subtle",
placement: "bottom-start",
scrollMode: "center",
},
};

export const TwentyFourHourFormat: Story = {
render: (args) => {
const [value, setValue] = useState("13:30");

return (
<div className="p-2 space-y-2">
<TimePicker {...args} value={value} onChange={setValue} />
<div className="text-xs text-ink-gray-6">Value: {value}</div>
</div>
);
},
name: "24 Hour Format",
args: {
use12Hour: false,
interval: 15,
allowCustom: true,
autoClose: true,
variant: "subtle",
placement: "bottom-start",
scrollMode: "center",
},
};

export const CustomOptions: Story = {
render: (args) => {
const [value, setValue] = useState("09:00");

return (
<div className="p-2 space-y-2">
<TimePicker {...args} value={value} onChange={setValue} />
<div className="text-xs text-ink-gray-6">Value: {value}</div>
</div>
);
},
name: "Custom Options (no interval generation)",
args: {
allowCustom: false,
autoClose: true,
use12Hour: true,
variant: "subtle",
placement: "bottom-start",
scrollMode: "center",
options: [
{ value: "08:00" },
{ value: "09:00" },
{ value: "09:30" },
{ value: "10:00" },
{ value: "11:15" },
{ value: "13:45" },
],
},
};

export const MinMaxRange: Story = {
render: (args) => {
const [value, setValue] = useState("08:00");

return (
<div className="p-2 space-y-2">
<TimePicker {...args} value={value} onChange={setValue} />
<div className="text-xs text-ink-gray-6">Value: {value}</div>
</div>
);
},
args: {
minTime: "08:00",
maxTime: "12:00",
interval: 15,
allowCustom: true,
autoClose: true,
use12Hour: true,
variant: "subtle",
placement: "bottom-start",
scrollMode: "center",
},
};
175 changes: 175 additions & 0 deletions packages/frappe-ui-react/src/components/timePicker/timePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* External dependencies.
*/
import React from "react";
import clsx from "clsx";

/**
* Internal dependencies.
*/
import Popover from "../popover/popover";
import TextInput from "../textInput/textInput";
import FeatherIcon from "../featherIcon";
import type { TimePickerProps, TimePickerOption } from "./types";
import { useTimePicker } from "./useTimePicker";

const TimePicker: React.FC<TimePickerProps> = ({
value = "",
onChange,
onInputInvalid,
onInvalidChange,
onOpen,
onClose,
interval = 15,
options = [],
placement = "bottom-start",
placeholder = "Select time",
variant = "subtle",
allowCustom = true,
autoClose = true,
use12Hour = true,
disabled = false,
scrollMode = "center",
minTime = "",
maxTime = "",
prefix,
suffix,
}) => {
const {
showOptions,
setShowOptions,
displayValue,
displayedOptions,
isTyping,
selectedAndNearest,
highlightIndex,
panelRef,
inputRef,
handleArrowDown,
handleArrowUp,
handleEnter,
handleClickInput,
handleFocus,
handleBlur,
handleEscape,
handleDisplayValueChange,
handleMouseEnter,
select,
optionId,
} = useTimePicker({
value,
onChange,
onInputInvalid,
onInvalidChange,
onOpen,
onClose,
interval,
options,
allowCustom,
autoClose,
use12Hour,
scrollMode,
minTime,
maxTime,
});

const getButtonClasses = (opt: TimePickerOption, idx: number): string => {
if (idx === highlightIndex) return "bg-surface-gray-3 text-ink-gray-8";
const { selected, nearest } = selectedAndNearest;
if (isTyping && !selected) {
if (nearest && nearest.value === opt.value)
return "text-ink-gray-7 italic bg-surface-gray-2";
return "text-ink-gray-6 hover:bg-surface-gray-2 hover:text-ink-gray-8";
}
if (selected && selected.value === opt.value)
return "bg-surface-gray-3 text-ink-gray-8";
if (nearest && nearest.value === opt.value)
return "text-ink-gray-7 italic bg-surface-gray-2";
return "text-ink-gray-6 hover:bg-surface-gray-2 hover:text-ink-gray-8";
};

return (
<Popover
show={showOptions && !disabled}
onUpdateShow={(show) => !disabled && setShowOptions(show)}
placement={placement}
target={({ togglePopover, isOpen }) => (
<TextInput
ref={inputRef}
value={displayValue}
onChange={handleDisplayValueChange}
variant={variant}
type="text"
placeholder={placeholder}
disabled={disabled}
readOnly={!allowCustom}
onFocus={handleFocus}
onClick={() => !disabled && handleClickInput(isOpen, togglePopover)}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") handleEnter(e);
else if (e.key === "Escape") handleEscape(e);
else if (e.key === "ArrowDown")
handleArrowDown(e, togglePopover, isOpen);
else if (e.key === "ArrowUp")
handleArrowUp(e, togglePopover, isOpen);
}}
Comment on lines +108 to +115
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The keyboard event handlers (Enter, Escape, ArrowDown, ArrowUp) are not guarded by the disabled state. When the component is disabled, these keyboard events will still trigger handlers like handleEnter, handleEscape, etc. Consider wrapping the keyboard event logic in a check for disabled state to prevent interaction when disabled.

Copilot uses AI. Check for mistakes.
onBlur={handleBlur}
prefix={prefix}
suffix={
suffix
? suffix
: () => (
<div
onClick={(e) => {
e.stopPropagation();
}}
>
<FeatherIcon
name="chevron-down"
className="h-4 w-4 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
togglePopover();
}}
Comment on lines +129 to +133
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default chevron-down icon always has cursor-pointer class, even when the component is disabled. When disabled, the cursor should not indicate the element is clickable. Consider conditionally applying the cursor style based on the disabled state, or wrapping this logic to check disabled before allowing interaction.

Suggested change
className="h-4 w-4 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
togglePopover();
}}
className={clsx("h-4 w-4", !disabled && "cursor-pointer")}
onMouseDown={
!disabled
? (e) => {
e.preventDefault();
togglePopover();
}
: undefined
}

Copilot uses AI. Check for mistakes.
/>
</div>
)
}
/>
)}
body={({ isOpen }) => (
<div
ref={panelRef}
style={{ display: isOpen ? "block" : "none" }}
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"
role="listbox"
aria-activedescendant={
highlightIndex >= 0 ? optionId(highlightIndex) : undefined
}
>
{displayedOptions.map((opt, idx) => (
<button
key={opt.value}
data-value={opt.value}
data-index={idx}
type="button"
className={clsx(
"group flex h-7 w-full items-center rounded px-2 text-left",
getButtonClasses(opt, idx)
)}
onClick={() => select(opt.value)}
onMouseEnter={() => handleMouseEnter(idx)}
role="option"
id={optionId(idx)}
aria-selected={opt.value === value}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The aria-selected attribute uses the original value prop instead of the normalized internalValue. This means if the prop is in 12-hour format (e.g., "2:30 pm") and options are in 24-hour format (e.g., "14:30"), no option will be marked as selected. Instead, use getBaseTime(internalValue) for proper comparison.

Suggested change
aria-selected={opt.value === value}
aria-selected={opt.value === getBaseTime(internalValue)}

Copilot uses AI. Check for mistakes.
>
<span className="truncate">{opt.label}</span>
</button>
))}
</div>
)}
/>
);
};

export default TimePicker;
Comment on lines +1 to +175
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component lacks test coverage. Other components in the codebase (such as autoComplete, checkbox, textInput, password) have comprehensive tests under a tests/ subdirectory. Given the complex interaction logic (keyboard navigation, input validation, option selection), unit tests should be added to ensure the component works as intended, especially for:

  • Time parsing and validation logic
  • Keyboard navigation (arrow keys, Enter, Escape)
  • Custom vs. preset time selection
  • Min/max range validation
  • 12-hour vs 24-hour format handling

Copilot uses AI. Check for mistakes.
Loading
Loading