-
Notifications
You must be signed in to change notification settings - Fork 13
Feat: Component TimePicker #205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", | ||
| }, | ||
| }; |
| 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); | ||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||
| 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
|
||||||||||||||||||||||||||||||
| 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
AI
Feb 5, 2026
There was a problem hiding this comment.
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.
| aria-selected={opt.value === value} | |
| aria-selected={opt.value === getBaseTime(internalValue)} |
Copilot
AI
Feb 5, 2026
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
disabledstate. When the component is disabled, these keyboard events will still trigger handlers likehandleEnter,handleEscape, etc. Consider wrapping the keyboard event logic in a check fordisabledstate to prevent interaction when disabled.