Skip to content

Commit e1ff693

Browse files
authored
Feat/1261 timepicker (#3612)
feat: add TimePicker component - New TimePicker component with hour/minute selection - Support for 12/24 hour formats - Keyboard navigation and accessibility support - Min/max time constraints - Validation and error handling - Summary view for read-only display - Comprehensive unit tests Closes #1261
1 parent 9ed8295 commit e1ff693

37 files changed

+4878
-0
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# TimePicker Component
2+
3+
A React component for time input with intelligent Chrome-like segment typing behavior.
4+
5+
## Overview
6+
7+
The TimePicker component provides an intuitive time input interface with separate segments for hours, minutes, seconds (optional), and AM/PM period (for 12-hour format). It features smart typing behavior that mimics Chrome's date/time input controls.
8+
9+
## Features
10+
11+
### Smart Typing Behavior
12+
13+
- **Auto-coercion**: Invalid entries are automatically corrected (e.g., typing "9" in hours becomes "09")
14+
- **Progressive completion**: Type digits sequentially to build complete values (e.g., "1" → "01", then "5" → "15")
15+
- **Buffer management**: Handles rapid typing with timeout-based commits to prevent race conditions
16+
- **Auto-advance**: Automatically moves to next segment when current segment is complete
17+
18+
### Keyboard Navigation
19+
20+
- **Arrow keys**: Navigate between segments and increment/decrement values
21+
- **Tab**: Standard tab navigation between segments
22+
- **Delete/Backspace**: Clear current segment
23+
- **Separators**: Type ":", ".", "," or space to advance to next segment
24+
25+
### Format Support
26+
27+
- **24-hour format**: "HH:mm" or "HH:mm:ss"
28+
- **12-hour format**: "HH:mm a" or "HH:mm:ss a" (with AM/PM)
29+
- **Flexible display**: Configurable time format with optional seconds
30+
31+
## Usage
32+
33+
```tsx
34+
import { TimePicker } from 'src/app-components/TimePicker/TimePicker';
35+
36+
// Basic usage
37+
<TimePicker
38+
id="time-input"
39+
value="14:30"
40+
onChange={(value) => console.log(value)}
41+
aria-label="Select time"
42+
/>
43+
44+
// With 12-hour format and seconds
45+
<TimePicker
46+
id="time-input"
47+
value="2:30:45 PM"
48+
format="HH:mm:ss a"
49+
onChange={(value) => console.log(value)}
50+
aria-label="Select appointment time"
51+
/>
52+
```
53+
54+
## Props
55+
56+
### Required Props
57+
58+
- `id: string` - Unique identifier for the component
59+
- `onChange: (value: string) => void` - Callback when time value changes
60+
- `aria-label: string` - Accessibility label for the time picker
61+
62+
### Optional Props
63+
64+
- `value?: string` - Current time value in the specified format
65+
- `format?: TimeFormat` - Time format string (default: "HH:mm")
66+
- `disabled?: boolean` - Whether the component is disabled
67+
- `readOnly?: boolean` - Whether the component is read-only
68+
- `className?: string` - Additional CSS classes
69+
- `placeholder?: string` - Placeholder text when empty
70+
71+
## Component Architecture
72+
73+
### Core Components
74+
75+
#### TimePicker (Main Component)
76+
77+
- Manages overall time state and validation
78+
- Handles format parsing and time value composition
79+
- Coordinates segment navigation and focus management
80+
81+
#### TimeSegment
82+
83+
- Individual input segment for hours, minutes, seconds, or period
84+
- Implements Chrome-like typing behavior with buffer management
85+
- Handles keyboard navigation and value coercion
86+
87+
### Supporting Modules
88+
89+
#### segmentTyping.ts
90+
91+
- **Input Processing**: Smart coercion logic for different segment types
92+
- **Buffer Management**: Handles multi-character input with timeouts
93+
- **Validation**: Ensures values stay within valid ranges
94+
95+
#### keyboardNavigation.ts
96+
97+
- **Navigation Logic**: Arrow key navigation between segments
98+
- **Value Manipulation**: Increment/decrement with arrow keys
99+
- **Key Handling**: Special key processing (Tab, Delete, etc.)
100+
101+
#### timeFormatUtils.ts
102+
103+
- **Format Parsing**: Converts format strings to display patterns
104+
- **Value Formatting**: Formats time values for display
105+
- **Validation**: Validates time format strings
106+
107+
## Typing Behavior Details
108+
109+
### Hour Input
110+
111+
- **24-hour mode**: First digit 0-2 waits for second digit, 3-9 auto-coerces to 0X
112+
- **12-hour mode**: First digit 0-1 waits for second digit, 2-9 auto-coerces to 0X
113+
- **Second digit**: Validates against first digit (e.g., 2X limited to 20-23 in 24-hour)
114+
115+
### Minute/Second Input
116+
117+
- **First digit**: 0-5 waits for second digit, 6-9 auto-coerces to 0X
118+
- **Second digit**: Always accepts 0-9
119+
- **Overflow handling**: Values > 59 are corrected during validation
120+
121+
### Period Input (AM/PM)
122+
123+
- **A/a key**: Sets to AM
124+
- **P/p key**: Sets to PM
125+
- **Case insensitive**: Accepts both upper and lower case
126+
127+
## Buffer Management
128+
129+
The component uses a sophisticated buffer system to handle rapid typing:
130+
131+
1. **Immediate Display**: Shows formatted value immediately as user types
132+
2. **Timeout Commit**: Commits buffered value after 1 second of inactivity
133+
3. **Race Condition Prevention**: Uses refs to avoid stale closure issues
134+
4. **State Synchronization**: Keeps buffer state in sync with React state
135+
136+
## Accessibility
137+
138+
- **ARIA Labels**: Each segment has descriptive aria-label
139+
- **Keyboard Navigation**: Full keyboard support for all interactions
140+
- **Focus Management**: Proper focus handling and visual indicators
141+
- **Screen Reader Support**: Announces current values and changes
142+
143+
## Testing
144+
145+
The component includes comprehensive tests covering:
146+
147+
- **Typing Scenarios**: Various input patterns and edge cases
148+
- **Navigation**: Keyboard navigation between segments
149+
- **Buffer Management**: Race condition prevention and timeout handling
150+
- **Format Support**: Different time formats and validation
151+
- **Accessibility**: Screen reader compatibility and ARIA support
152+
153+
## Browser Compatibility
154+
155+
Designed to work consistently across modern browsers with Chrome-like behavior as the reference implementation.
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
.calendarInputWrapper {
2+
display: flex;
3+
align-items: center;
4+
border-radius: 4px;
5+
border: var(--ds-border-width-default, 1px) solid var(--ds-color-neutral-border-strong);
6+
gap: var(--ds-size-1);
7+
background: white;
8+
padding: 2px;
9+
}
10+
11+
.calendarInputWrapper button {
12+
margin: 1px;
13+
}
14+
15+
.calendarInputWrapper:hover {
16+
box-shadow: inset 0 0 0 1px var(--ds-color-accent-border-strong);
17+
}
18+
19+
.segmentContainer {
20+
display: flex;
21+
align-items: center;
22+
flex: 1;
23+
padding: 0 4px;
24+
}
25+
26+
.segmentContainer input {
27+
border: none;
28+
background: transparent;
29+
padding: 4px 2px;
30+
text-align: center;
31+
font-family: inherit;
32+
font-size: inherit;
33+
}
34+
35+
.segmentContainer input:focus-visible {
36+
outline: 2px solid var(--ds-color-neutral-text-default);
37+
outline-offset: 0;
38+
border-radius: var(--ds-border-radius-sm);
39+
box-shadow: 0 0 0 2px var(--ds-color-neutral-background-default);
40+
}
41+
42+
.segmentSeparator {
43+
color: var(--ds-color-neutral-text-subtle);
44+
user-select: none;
45+
padding: 0 2px;
46+
}
47+
48+
.timePickerWrapper {
49+
position: relative;
50+
display: inline-block;
51+
}
52+
53+
.timePickerDropdown {
54+
min-width: 280px;
55+
max-width: 400px;
56+
padding: 8px;
57+
box-sizing: border-box;
58+
}
59+
60+
.dropdownColumns {
61+
display: flex;
62+
gap: 8px;
63+
width: 100%;
64+
box-sizing: border-box;
65+
padding: 2px;
66+
}
67+
68+
.dropdownColumn {
69+
flex: 1;
70+
min-width: 0;
71+
max-width: 100px;
72+
overflow: hidden;
73+
}
74+
75+
.dropdownLabel {
76+
display: block;
77+
font-size: 0.875rem;
78+
font-weight: 500;
79+
color: var(--ds-color-neutral-text-default);
80+
margin-bottom: 4px;
81+
}
82+
83+
.dropdownTrigger {
84+
width: 100%;
85+
min-width: 60px;
86+
}
87+
88+
.dropdownList {
89+
max-height: 160px;
90+
overflow-y: auto;
91+
overflow-x: hidden;
92+
border: 1px solid var(--ds-color-neutral-border-subtle);
93+
border-radius: var(--ds-border-radius-md);
94+
padding: 4px;
95+
margin: 2px;
96+
box-sizing: border-box;
97+
width: calc(100% - 4px);
98+
position: relative;
99+
}
100+
101+
.dropdownListFocused {
102+
outline: 2px solid var(--ds-color-accent-border-strong);
103+
outline-offset: 0;
104+
}
105+
106+
.dropdownOption {
107+
width: 100%;
108+
padding: 6px 4px;
109+
border: none;
110+
background: transparent;
111+
font-size: 0.875rem;
112+
font-family: inherit;
113+
text-align: center;
114+
cursor: pointer;
115+
color: var(--ds-color-neutral-text-default);
116+
transition: background-color 0.15s ease;
117+
white-space: nowrap;
118+
overflow: hidden;
119+
text-overflow: ellipsis;
120+
}
121+
122+
.dropdownOption:hover {
123+
background-color: var(--ds-color-accent-surface-hover);
124+
}
125+
126+
.dropdownOptionSelected {
127+
background-color: var(--ds-color-accent-base-active) !important;
128+
color: white;
129+
font-weight: 500;
130+
}
131+
132+
.dropdownOptionSelected:hover {
133+
background-color: var(--ds-color-accent-base-active) !important;
134+
}
135+
136+
.dropdownOptionFocused {
137+
outline: 2px solid var(--ds-color-accent-border-strong);
138+
outline-offset: -2px;
139+
background-color: var(--ds-color-accent-surface-hover);
140+
position: relative;
141+
z-index: 1;
142+
}
143+
144+
.dropdownOptionDisabled {
145+
opacity: 0.5;
146+
cursor: not-allowed;
147+
color: var(--ds-color-neutral-text-subtle);
148+
}
149+
150+
.dropdownOptionDisabled:hover {
151+
background-color: transparent;
152+
}
153+
154+
/* Scrollbar styling for dropdown lists */
155+
.dropdownList::-webkit-scrollbar {
156+
width: 4px;
157+
}
158+
159+
.dropdownList::-webkit-scrollbar-track {
160+
background: var(--ds-color-neutral-background-subtle);
161+
border-radius: 2px;
162+
}
163+
164+
.dropdownList::-webkit-scrollbar-thumb {
165+
background: var(--ds-color-neutral-border-default);
166+
border-radius: 2px;
167+
}
168+
169+
.dropdownList::-webkit-scrollbar-thumb:hover {
170+
background: var(--ds-color-neutral-border-strong);
171+
}
172+
173+
/* Responsive styles */
174+
@media (max-width: 348px) {
175+
.calendarInputWrapper {
176+
flex-wrap: wrap;
177+
gap: var(--ds-size-1);
178+
}
179+
180+
.segmentContainer {
181+
flex: 1 1 auto;
182+
min-width: 150px;
183+
}
184+
185+
.dropdownColumns {
186+
flex-wrap: wrap;
187+
gap: 8px;
188+
}
189+
190+
.dropdownColumn {
191+
min-width: calc(50% - 4px);
192+
max-width: none;
193+
}
194+
195+
.timePickerDropdown {
196+
max-width: 95vw;
197+
}
198+
}
199+
200+
@media (max-width: 205px) {
201+
.calendarInputWrapper {
202+
flex-direction: column;
203+
align-items: stretch;
204+
}
205+
206+
.segmentContainer {
207+
justify-content: center;
208+
width: 100%;
209+
min-width: unset;
210+
}
211+
212+
.segmentContainer input {
213+
width: 2.5rem;
214+
font-size: 0.875rem;
215+
}
216+
217+
.dropdownColumns {
218+
flex-direction: column;
219+
gap: 4px;
220+
}
221+
222+
.dropdownColumn {
223+
min-width: 100%;
224+
max-width: 100%;
225+
}
226+
227+
.dropdownList {
228+
max-height: 100px;
229+
}
230+
}

0 commit comments

Comments
 (0)