diff --git a/src/app-components/TimePicker/README.md b/src/app-components/TimePicker/README.md new file mode 100644 index 0000000000..b575b0bc6d --- /dev/null +++ b/src/app-components/TimePicker/README.md @@ -0,0 +1,155 @@ +# TimePicker Component + +A React component for time input with intelligent Chrome-like segment typing behavior. + +## Overview + +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. + +## Features + +### Smart Typing Behavior + +- **Auto-coercion**: Invalid entries are automatically corrected (e.g., typing "9" in hours becomes "09") +- **Progressive completion**: Type digits sequentially to build complete values (e.g., "1" → "01", then "5" → "15") +- **Buffer management**: Handles rapid typing with timeout-based commits to prevent race conditions +- **Auto-advance**: Automatically moves to next segment when current segment is complete + +### Keyboard Navigation + +- **Arrow keys**: Navigate between segments and increment/decrement values +- **Tab**: Standard tab navigation between segments +- **Delete/Backspace**: Clear current segment +- **Separators**: Type ":", ".", "," or space to advance to next segment + +### Format Support + +- **24-hour format**: "HH:mm" or "HH:mm:ss" +- **12-hour format**: "HH:mm a" or "HH:mm:ss a" (with AM/PM) +- **Flexible display**: Configurable time format with optional seconds + +## Usage + +```tsx +import { TimePicker } from 'src/app-components/TimePicker/TimePicker'; + +// Basic usage + console.log(value)} + aria-label="Select time" +/> + +// With 12-hour format and seconds + console.log(value)} + aria-label="Select appointment time" +/> +``` + +## Props + +### Required Props + +- `id: string` - Unique identifier for the component +- `onChange: (value: string) => void` - Callback when time value changes +- `aria-label: string` - Accessibility label for the time picker + +### Optional Props + +- `value?: string` - Current time value in the specified format +- `format?: TimeFormat` - Time format string (default: "HH:mm") +- `disabled?: boolean` - Whether the component is disabled +- `readOnly?: boolean` - Whether the component is read-only +- `className?: string` - Additional CSS classes +- `placeholder?: string` - Placeholder text when empty + +## Component Architecture + +### Core Components + +#### TimePicker (Main Component) + +- Manages overall time state and validation +- Handles format parsing and time value composition +- Coordinates segment navigation and focus management + +#### TimeSegment + +- Individual input segment for hours, minutes, seconds, or period +- Implements Chrome-like typing behavior with buffer management +- Handles keyboard navigation and value coercion + +### Supporting Modules + +#### segmentTyping.ts + +- **Input Processing**: Smart coercion logic for different segment types +- **Buffer Management**: Handles multi-character input with timeouts +- **Validation**: Ensures values stay within valid ranges + +#### keyboardNavigation.ts + +- **Navigation Logic**: Arrow key navigation between segments +- **Value Manipulation**: Increment/decrement with arrow keys +- **Key Handling**: Special key processing (Tab, Delete, etc.) + +#### timeFormatUtils.ts + +- **Format Parsing**: Converts format strings to display patterns +- **Value Formatting**: Formats time values for display +- **Validation**: Validates time format strings + +## Typing Behavior Details + +### Hour Input + +- **24-hour mode**: First digit 0-2 waits for second digit, 3-9 auto-coerces to 0X +- **12-hour mode**: First digit 0-1 waits for second digit, 2-9 auto-coerces to 0X +- **Second digit**: Validates against first digit (e.g., 2X limited to 20-23 in 24-hour) + +### Minute/Second Input + +- **First digit**: 0-5 waits for second digit, 6-9 auto-coerces to 0X +- **Second digit**: Always accepts 0-9 +- **Overflow handling**: Values > 59 are corrected during validation + +### Period Input (AM/PM) + +- **A/a key**: Sets to AM +- **P/p key**: Sets to PM +- **Case insensitive**: Accepts both upper and lower case + +## Buffer Management + +The component uses a sophisticated buffer system to handle rapid typing: + +1. **Immediate Display**: Shows formatted value immediately as user types +2. **Timeout Commit**: Commits buffered value after 1 second of inactivity +3. **Race Condition Prevention**: Uses refs to avoid stale closure issues +4. **State Synchronization**: Keeps buffer state in sync with React state + +## Accessibility + +- **ARIA Labels**: Each segment has descriptive aria-label +- **Keyboard Navigation**: Full keyboard support for all interactions +- **Focus Management**: Proper focus handling and visual indicators +- **Screen Reader Support**: Announces current values and changes + +## Testing + +The component includes comprehensive tests covering: + +- **Typing Scenarios**: Various input patterns and edge cases +- **Navigation**: Keyboard navigation between segments +- **Buffer Management**: Race condition prevention and timeout handling +- **Format Support**: Different time formats and validation +- **Accessibility**: Screen reader compatibility and ARIA support + +## Browser Compatibility + +Designed to work consistently across modern browsers with Chrome-like behavior as the reference implementation. diff --git a/src/app-components/TimePicker/TimePicker.module.css b/src/app-components/TimePicker/TimePicker.module.css new file mode 100644 index 0000000000..366e807857 --- /dev/null +++ b/src/app-components/TimePicker/TimePicker.module.css @@ -0,0 +1,230 @@ +.calendarInputWrapper { + display: flex; + align-items: center; + border-radius: 4px; + border: var(--ds-border-width-default, 1px) solid var(--ds-color-neutral-border-strong); + gap: var(--ds-size-1); + background: white; + padding: 2px; +} + +.calendarInputWrapper button { + margin: 1px; +} + +.calendarInputWrapper:hover { + box-shadow: inset 0 0 0 1px var(--ds-color-accent-border-strong); +} + +.segmentContainer { + display: flex; + align-items: center; + flex: 1; + padding: 0 4px; +} + +.segmentContainer input { + border: none; + background: transparent; + padding: 4px 2px; + text-align: center; + font-family: inherit; + font-size: inherit; +} + +.segmentContainer input:focus-visible { + outline: 2px solid var(--ds-color-neutral-text-default); + outline-offset: 0; + border-radius: var(--ds-border-radius-sm); + box-shadow: 0 0 0 2px var(--ds-color-neutral-background-default); +} + +.segmentSeparator { + color: var(--ds-color-neutral-text-subtle); + user-select: none; + padding: 0 2px; +} + +.timePickerWrapper { + position: relative; + display: inline-block; +} + +.timePickerDropdown { + min-width: 280px; + max-width: 400px; + padding: 8px; + box-sizing: border-box; +} + +.dropdownColumns { + display: flex; + gap: 8px; + width: 100%; + box-sizing: border-box; + padding: 2px; +} + +.dropdownColumn { + flex: 1; + min-width: 0; + max-width: 100px; + overflow: hidden; +} + +.dropdownLabel { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--ds-color-neutral-text-default); + margin-bottom: 4px; +} + +.dropdownTrigger { + width: 100%; + min-width: 60px; +} + +.dropdownList { + max-height: 160px; + overflow-y: auto; + overflow-x: hidden; + border: 1px solid var(--ds-color-neutral-border-subtle); + border-radius: var(--ds-border-radius-md); + padding: 4px; + margin: 2px; + box-sizing: border-box; + width: calc(100% - 4px); + position: relative; +} + +.dropdownListFocused { + outline: 2px solid var(--ds-color-accent-border-strong); + outline-offset: 0; +} + +.dropdownOption { + width: 100%; + padding: 6px 4px; + border: none; + background: transparent; + font-size: 0.875rem; + font-family: inherit; + text-align: center; + cursor: pointer; + color: var(--ds-color-neutral-text-default); + transition: background-color 0.15s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dropdownOption:hover { + background-color: var(--ds-color-accent-surface-hover); +} + +.dropdownOptionSelected { + background-color: var(--ds-color-accent-base-active) !important; + color: white; + font-weight: 500; +} + +.dropdownOptionSelected:hover { + background-color: var(--ds-color-accent-base-active) !important; +} + +.dropdownOptionFocused { + outline: 2px solid var(--ds-color-accent-border-strong); + outline-offset: -2px; + background-color: var(--ds-color-accent-surface-hover); + position: relative; + z-index: 1; +} + +.dropdownOptionDisabled { + opacity: 0.5; + cursor: not-allowed; + color: var(--ds-color-neutral-text-subtle); +} + +.dropdownOptionDisabled:hover { + background-color: transparent; +} + +/* Scrollbar styling for dropdown lists */ +.dropdownList::-webkit-scrollbar { + width: 4px; +} + +.dropdownList::-webkit-scrollbar-track { + background: var(--ds-color-neutral-background-subtle); + border-radius: 2px; +} + +.dropdownList::-webkit-scrollbar-thumb { + background: var(--ds-color-neutral-border-default); + border-radius: 2px; +} + +.dropdownList::-webkit-scrollbar-thumb:hover { + background: var(--ds-color-neutral-border-strong); +} + +/* Responsive styles */ +@media (max-width: 348px) { + .calendarInputWrapper { + flex-wrap: wrap; + gap: var(--ds-size-1); + } + + .segmentContainer { + flex: 1 1 auto; + min-width: 150px; + } + + .dropdownColumns { + flex-wrap: wrap; + gap: 8px; + } + + .dropdownColumn { + min-width: calc(50% - 4px); + max-width: none; + } + + .timePickerDropdown { + max-width: 95vw; + } +} + +@media (max-width: 205px) { + .calendarInputWrapper { + flex-direction: column; + align-items: stretch; + } + + .segmentContainer { + justify-content: center; + width: 100%; + min-width: unset; + } + + .segmentContainer input { + width: 2.5rem; + font-size: 0.875rem; + } + + .dropdownColumns { + flex-direction: column; + gap: 4px; + } + + .dropdownColumn { + min-width: 100%; + max-width: 100%; + } + + .dropdownList { + max-height: 100px; + } +} diff --git a/src/app-components/TimePicker/TimePicker.responsive.test.tsx b/src/app-components/TimePicker/TimePicker.responsive.test.tsx new file mode 100644 index 0000000000..5d54e9c274 --- /dev/null +++ b/src/app-components/TimePicker/TimePicker.responsive.test.tsx @@ -0,0 +1,240 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { TimePicker } from 'src/app-components/TimePicker/TimePicker'; + +describe('TimePicker - Responsive & Accessibility', () => { + const defaultProps = { + id: 'test-timepicker', + value: '14:30', + onChange: jest.fn(), + }; + + beforeAll(() => { + // Mock getComputedStyle to avoid JSDOM errors with Popover + Object.defineProperty(window, 'getComputedStyle', { + value: () => ({ + getPropertyValue: () => '', + position: 'absolute', + top: '0px', + left: '0px', + width: '300px', + height: '200px', + }), + writable: true, + }); + }); + + describe('Responsive Behavior', () => { + const originalInnerWidth = window.innerWidth; + + afterEach(() => { + // Reset window width + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: originalInnerWidth, + }); + }); + + it('should render at 205px width (smallest breakpoint)', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 205, + }); + + render(); + + const wrapper = screen.getByRole('textbox', { name: /hours/i }).closest('.calendarInputWrapper'); + expect(wrapper).toBeInTheDocument(); + + // Component should still be functional + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); // hours and minutes + }); + + it('should render at 348px width (medium breakpoint)', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 348, + }); + + render(); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + // All inputs should be visible + inputs.forEach((input) => { + expect(input).toBeVisible(); + }); + }); + + it('should handle long format at small widths', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 205, + }); + + render( + , + ); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(4); // All segments should render + + // Verify all inputs are accessible even at small width + inputs.forEach((input) => { + expect(input).toBeInTheDocument(); + }); + }); + }); + + describe('Screen Reader Accessibility', () => { + it('should have proper aria-labels for all inputs', () => { + render(); + + const hoursInput = screen.getByRole('textbox', { name: /hours/i }); + const minutesInput = screen.getByRole('textbox', { name: /minutes/i }); + + expect(hoursInput).toHaveAttribute('aria-label', 'Hours'); + expect(minutesInput).toHaveAttribute('aria-label', 'Minutes'); + }); + + it('should have proper aria-labels with custom labels', () => { + render( + , + ); + + const hoursInput = screen.getByRole('textbox', { name: /timer/i }); + const minutesInput = screen.getByRole('textbox', { name: /minutter/i }); + + expect(hoursInput).toHaveAttribute('aria-label', 'Timer'); + expect(minutesInput).toHaveAttribute('aria-label', 'Minutter'); + }); + + it('should have proper aria-labels for 12-hour format', () => { + render( + , + ); + + const hoursInput = screen.getByRole('textbox', { name: /hours/i }); + const minutesInput = screen.getByRole('textbox', { name: /minutes/i }); + const periodInput = screen.getByRole('textbox', { name: /am\/pm/i }); + + expect(hoursInput).toHaveAttribute('aria-label', 'Hours'); + expect(minutesInput).toHaveAttribute('aria-label', 'Minutes'); + expect(periodInput).toHaveAttribute('aria-label', 'AM/PM'); + }); + + it('should have proper aria-labels with seconds', () => { + render( + , + ); + + const secondsInput = screen.getByRole('textbox', { name: /seconds/i }); + expect(secondsInput).toHaveAttribute('aria-label', 'Seconds'); + }); + + it('should have accessible dropdown dialog', () => { + render(); + + const clockButton = screen.getByRole('button', { name: /open time picker/i }); + expect(clockButton).toHaveAttribute('aria-label', 'Open time picker'); + }); + + it('should announce dropdown state to screen readers', async () => { + const user = userEvent.setup(); + render(); + + const clockButton = screen.getByRole('button', { name: /open time picker/i }); + await user.click(clockButton); + + // The Popover component doesn't set role="dialog" but does set aria-modal + const dropdown = screen.getByLabelText('Time selection dropdown'); + expect(dropdown).toHaveAttribute('aria-modal', 'true'); + }); + + it('should maintain semantic structure for screen readers', () => { + render( + , + ); + + // All inputs should have proper roles + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(4); + + // Each should have an aria-label + inputs.forEach((input) => { + expect(input).toHaveAttribute('aria-label'); + }); + + // Clock button should be accessible + const clockButton = screen.getByRole('button', { name: /open time picker/i }); + expect(clockButton).toHaveAttribute('aria-label'); + }); + }); + + describe('Disabled State Accessibility', () => { + it('should properly indicate disabled state to screen readers', () => { + render( + , + ); + + const inputs = screen.getAllByRole('textbox'); + inputs.forEach((input) => { + expect(input).toBeDisabled(); + }); + + const clockButton = screen.getByRole('button', { name: /open time picker/i }); + expect(clockButton).toBeDisabled(); + }); + + it('should properly indicate readonly state', () => { + render( + , + ); + + const inputs = screen.getAllByRole('textbox'); + inputs.forEach((input) => { + expect(input).toHaveAttribute('readonly'); + }); + + const clockButton = screen.getByRole('button', { name: /open time picker/i }); + expect(clockButton).toBeDisabled(); + }); + }); +}); diff --git a/src/app-components/TimePicker/TimePicker.tsx b/src/app-components/TimePicker/TimePicker.tsx new file mode 100644 index 0000000000..8fb8d87d0c --- /dev/null +++ b/src/app-components/TimePicker/TimePicker.tsx @@ -0,0 +1,704 @@ +import React, { useRef, useState } from 'react'; + +import { Popover } from '@digdir/designsystemet-react'; +import { ClockIcon } from '@navikt/aksel-icons'; + +import styles from 'src/app-components/TimePicker/TimePicker.module.css'; +import { TimeSegment } from 'src/app-components/TimePicker/TimeSegment/TimeSegment'; +import { calculateNextFocusState } from 'src/app-components/TimePicker/utils/calculateNextFocusState/calculateNextFocusState'; +import { formatDisplayHour } from 'src/app-components/TimePicker/utils/formatDisplayHour/formatDisplayHour'; +import { + generateHourOptions, + generateMinuteOptions, + generateSecondOptions, +} from 'src/app-components/TimePicker/utils/generateTimeOptions/generateTimeOptions'; +import { handleSegmentValueChange } from 'src/app-components/TimePicker/utils/handleSegmentValueChange/handleSegmentValueChange'; +import { normalizeHour } from 'src/app-components/TimePicker/utils/normalizeHour'; +import { getSegmentConstraints, parseTimeString } from 'src/app-components/TimePicker/utils/timeConstraintUtils'; +import { formatTimeValue } from 'src/app-components/TimePicker/utils/timeFormatUtils'; +import type { + DropdownFocusState, + NavigationAction, + SegmentType, + TimeConstraints, + TimePickerProps, + TimeValue, +} from 'src/app-components/TimePicker/types'; + +export const TimePicker: React.FC = ({ + id, + value, + onChange, + format = 'HH:mm', + minTime, + maxTime, + disabled = false, + readOnly = false, + labels = {}, +}) => { + const timeValue = parseTimeString(value, format); + + const [showDropdown, setShowDropdown] = useState(false); + + // Dropdown keyboard navigation state + const [dropdownFocus, setDropdownFocus] = useState({ + column: 0, // 0=hours, 1=minutes, 2=seconds, 3=period + option: -1, // index within current column, -1 means no focus + isActive: false, // is keyboard navigation active + }); + + const segmentRefs = useRef<(HTMLInputElement | null)[]>([]); + const hoursListRef = useRef(null); + const minutesListRef = useRef(null); + const secondsListRef = useRef(null); + const periodListRef = useRef(null); + const dropdownRef = useRef(null); + const triggerButtonRef = useRef(null); + + const is12Hour = format.includes('a'); + const includesSeconds = format.includes('ss'); + + // Define segments based on format + const segments: SegmentType[] = ['hours', 'minutes']; + if (includesSeconds) { + segments.push('seconds'); + } + if (is12Hour) { + segments.push('period'); + } + + const constraints: TimeConstraints = { + minTime, + maxTime, + }; + + const segmentLabels = { + hours: labels.hours || 'Hours', + minutes: labels.minutes || 'Minutes', + seconds: labels.seconds || 'Seconds', + period: labels.amPm || 'AM/PM', + }; + + const segmentPlaceholders = { + hours: 'HH', + minutes: 'MM', + seconds: 'SS', + period: 'AM', + }; + + const scrollToSelectedOptions = () => { + requestAnimationFrame(() => { + const scrollToSelected = (container: HTMLDivElement | null) => { + if (!container) { + return; + } + + const selectedOption = container.querySelector(`.${styles.dropdownOptionSelected}`) as HTMLElement; + if (!selectedOption) { + return; + } + + const containerHeight = container.offsetHeight; + const elementTop = selectedOption.offsetTop; + const elementHeight = selectedOption.offsetHeight; + + container.scrollTop = elementTop - containerHeight / 2 + elementHeight / 2; + }; + + scrollToSelected(hoursListRef.current); + scrollToSelected(minutesListRef.current); + scrollToSelected(secondsListRef.current); + }); + }; + + const updateTime = (updates: Partial) => { + const newTime = { ...timeValue, ...updates }; + onChange(formatTimeValue(newTime, format)); + }; + + const handleSegmentChange = (segmentType: SegmentType, newValue: number | string) => { + const segmentConstraints = + segmentType !== 'period' + ? getSegmentConstraints(segmentType, timeValue, constraints, format) + : { min: 0, max: 0, validValues: [] }; + + const result = handleSegmentValueChange(segmentType, newValue, timeValue, segmentConstraints, is12Hour); + + updateTime(result.updatedTimeValue); + }; + + const handleSegmentNavigate = (direction: 'left' | 'right', currentIndex: number) => { + let nextIndex: number; + + if (direction === 'right') { + nextIndex = (currentIndex + 1) % segments.length; + } else { + nextIndex = (currentIndex - 1 + segments.length) % segments.length; + } + + segmentRefs.current[nextIndex]?.focus(); + }; + + const closeDropdown = () => { + setShowDropdown(false); + setDropdownFocus({ column: 0, option: -1, isActive: false }); + }; + + const getOptionButton = (columnIndex: number, optionIndex: number): HTMLButtonElement | null => { + const getContainerRef = () => { + switch (columnIndex) { + case 0: + return hoursListRef.current; + case 1: + return minutesListRef.current; + case 2: + return includesSeconds ? secondsListRef.current : periodListRef.current; + case 3: + return periodListRef.current; + default: + return null; + } + }; + + const container = getContainerRef(); + if (!container) { + return null; + } + + const buttons = container.querySelectorAll('button'); + return buttons[optionIndex]; + }; + + const scrollFocusedOptionIntoView = (columnIndex: number, optionIndex: number) => { + const getContainerRef = () => { + switch (columnIndex) { + case 0: + return hoursListRef.current; + case 1: + return minutesListRef.current; + case 2: + return includesSeconds ? secondsListRef.current : null; // AM/PM doesn't need scrolling + case 3: + return null; // AM/PM doesn't need scrolling + default: + return null; + } + }; + + const container = getContainerRef(); + if (!container) { + return; + } + + const options = container.children; + const focusedOption = options[optionIndex]; + + if (focusedOption) { + focusedOption.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + } + }; + + const getCurrentColumnOptions = (columnIndex: number) => { + switch (columnIndex) { + case 0: + return hourOptions; + case 1: + return minuteOptions; + case 2: { + if (includesSeconds) { + return secondOptions; + } + return is12Hour ? [{ value: 'AM' }, { value: 'PM' }] : []; + } + case 3: + return [{ value: 'AM' }, { value: 'PM' }]; + default: + return []; + } + }; + + // Helper function to handle value updates for different columns + const updateColumnValue = (columnIndex: number, optionIndex: number) => { + const options = getCurrentColumnOptions(columnIndex); + const option = options[optionIndex]; + if (!option) { + return; + } + + switch (columnIndex) { + case 0: // Hours + handleDropdownHoursChange(option.value.toString()); + break; + case 1: // Minutes + handleDropdownMinutesChange(option.value.toString()); + break; + case 2: // Seconds or AM/PM (if no seconds) + if (includesSeconds) { + handleDropdownSecondsChange(option.value.toString()); + } else if (is12Hour) { + handleDropdownPeriodChange(option.value as 'AM' | 'PM'); + } + break; + case 3: // AM/PM (if seconds included) + if (is12Hour && includesSeconds) { + handleDropdownPeriodChange(option.value as 'AM' | 'PM'); + } + break; + } + }; + + // Get column option counts for navigation + const getOptionCounts = (): number[] => { + const counts = [hourOptions.length, minuteOptions.length]; + if (includesSeconds) { + counts.push(secondOptions.length); + } + if (is12Hour) { + counts.push(2); + } // AM/PM + return counts; + }; + + // Get max columns for navigation + const getMaxColumns = (): number => { + let maxColumns = 2; // hours, minutes + if (includesSeconds) { + maxColumns++; + } + if (is12Hour) { + maxColumns++; + } + return maxColumns; + }; + + // Navigate up/down within current column + const navigateUpDown = (direction: 'up' | 'down') => { + const action: NavigationAction = { type: direction === 'up' ? 'ARROW_UP' : 'ARROW_DOWN' }; + const newFocus = calculateNextFocusState(dropdownFocus, action, getMaxColumns(), getOptionCounts()); + + setDropdownFocus(newFocus); + + // Focus the actual button element with preventScroll to handle scrolling ourselves + const button = getOptionButton(newFocus.column, newFocus.option); + if (button) { + button.focus({ preventScroll: true }); + } + + updateColumnValue(newFocus.column, newFocus.option); + scrollFocusedOptionIntoView(newFocus.column, newFocus.option); + }; + + // Navigate left/right between columns + const navigateLeftRight = (direction: 'left' | 'right') => { + const action: NavigationAction = { type: direction === 'left' ? 'ARROW_LEFT' : 'ARROW_RIGHT' }; + const newFocus = calculateNextFocusState(dropdownFocus, action, getMaxColumns(), getOptionCounts()); + + setDropdownFocus(newFocus); + + // Focus the actual button element + const button = getOptionButton(newFocus.column, newFocus.option); + if (button) { + button.focus(); + } + }; + + // Handle keyboard navigation in dropdown + const handleDropdownKeyDown = (event: React.KeyboardEvent) => { + if (!dropdownFocus.isActive) { + return; + } + + const keyActionMap: Record = { + ArrowUp: { type: 'ARROW_UP' }, + ArrowDown: { type: 'ARROW_DOWN' }, + ArrowLeft: { type: 'ARROW_LEFT' }, + ArrowRight: { type: 'ARROW_RIGHT' }, + Enter: { type: 'ENTER' }, + Escape: { type: 'ESCAPE' }, + }; + + const action = keyActionMap[event.key]; + if (!action) { + return; + } + + event.preventDefault(); + + switch (action.type) { + case 'ARROW_UP': + navigateUpDown('up'); + break; + case 'ARROW_DOWN': + navigateUpDown('down'); + break; + case 'ARROW_LEFT': + navigateLeftRight('left'); + break; + case 'ARROW_RIGHT': + navigateLeftRight('right'); + break; + case 'ENTER': + case 'ESCAPE': + setDropdownFocus(calculateNextFocusState(dropdownFocus, action, getMaxColumns(), getOptionCounts())); + closeDropdown(); + break; + } + }; + + const displayHours = formatDisplayHour(timeValue.hours, is12Hour); + + const hourOptions = generateHourOptions(is12Hour); + const minuteOptions = generateMinuteOptions(1); + const secondOptions = generateSecondOptions(1); + + const handleDropdownHoursChange = (selectedHour: string) => { + const hour = parseInt(selectedHour, 10); + if (is12Hour) { + let newHour = hour; + if (timeValue.period === 'AM' && hour === 12) { + newHour = 0; + } else if (timeValue.period === 'PM' && hour !== 12) { + newHour += 12; + } + updateTime({ hours: newHour }); + } else { + updateTime({ hours: hour }); + } + }; + + const handleDropdownMinutesChange = (selectedMinute: string) => { + updateTime({ minutes: parseInt(selectedMinute, 10) }); + }; + + const handleDropdownSecondsChange = (selectedSecond: string) => { + updateTime({ seconds: parseInt(selectedSecond, 10) }); + }; + + const handleDropdownPeriodChange = (period: 'AM' | 'PM') => { + let newHours = timeValue.hours; + if (period === 'PM' && timeValue.hours < 12) { + newHours += 12; + } else if (period === 'AM' && timeValue.hours >= 12) { + newHours -= 12; + } + updateTime({ period, hours: newHours }); + }; + + return ( +
+
+ {segments.map((segmentType, index) => { + const segmentValue = segmentType === 'period' ? timeValue.period || 'AM' : timeValue[segmentType]; + const segmentConstraints = + segmentType !== 'period' + ? getSegmentConstraints(segmentType as 'hours' | 'minutes' | 'seconds', timeValue, constraints, format) + : { min: 0, max: 0, validValues: [] }; + + return ( + + {index > 0 && segmentType !== 'period' && :} + {index > 0 && segmentType === 'period' &&  } + { + segmentRefs.current[index] = el; + }} + value={segmentValue} + min={segmentConstraints.min} + max={segmentConstraints.max} + type={segmentType} + format={format} + onValueChange={(newValue) => handleSegmentChange(segmentType, newValue)} + onNavigate={(direction) => handleSegmentNavigate(direction, index)} + placeholder={segmentPlaceholders[segmentType]} + disabled={disabled} + readOnly={readOnly} + aria-label={segmentLabels[segmentType]} + aria-describedby={`${id}-label`} + autoFocus={index === 0} + /> + + ); + })} +
+ + + { + setShowDropdown(!showDropdown); + }} + > + + + { + // Initialize dropdown focus on the currently selected hour + const currentHourIndex = hourOptions.findIndex((option) => option.value === displayHours); + const initialFocus = { + column: 0, // Start with hours column + option: Math.max(0, currentHourIndex), + isActive: true, + }; + setDropdownFocus(initialFocus); + + scrollToSelectedOptions(); + + // Focus the initial selected option after DOM is ready with preventScroll + requestAnimationFrame(() => { + const button = getOptionButton(initialFocus.column, initialFocus.option); + button?.focus({ preventScroll: true }); + }); + }} + onClose={() => { + closeDropdown(); + }} + onKeyDown={handleDropdownKeyDown} + > +
+ {/* Hours Column */} +
+
{segmentLabels.hours}
+
+ {hourOptions.map((option, optionIndex) => { + const normalizedHour = normalizeHour(option.value, is12Hour, timeValue.period || 'AM'); + + const isDisabled = + constraints.minTime || constraints.maxTime + ? !getSegmentConstraints('hours', timeValue, constraints, format).validValues.includes( + normalizedHour, + ) + : false; + + const isSelected = option.value === displayHours; + const isFocused = + dropdownFocus.isActive && dropdownFocus.column === 0 && dropdownFocus.option === optionIndex; + + return ( + + ); + })} +
+
+ + {/* Minutes Column */} +
+
{segmentLabels.minutes}
+
+ {minuteOptions.map((option, optionIndex) => { + const isDisabled = + constraints.minTime || constraints.maxTime + ? !getSegmentConstraints('minutes', timeValue, constraints, format).validValues.includes( + option.value, + ) + : false; + + const isSelected = option.value === timeValue.minutes; + const isFocused = + dropdownFocus.isActive && dropdownFocus.column === 1 && dropdownFocus.option === optionIndex; + + return ( + + ); + })} +
+
+ + {/* Seconds Column (if included) */} + {includesSeconds && ( +
+
Sekunder
+
+ {secondOptions.map((option, optionIndex) => { + const isDisabled = + constraints.minTime || constraints.maxTime + ? !getSegmentConstraints('seconds', timeValue, constraints, format).validValues.includes( + option.value, + ) + : false; + + const isSelected = option.value === timeValue.seconds; + const isFocused = + dropdownFocus.isActive && dropdownFocus.column === 2 && dropdownFocus.option === optionIndex; + + return ( + + ); + })} +
+
+ )} + + {/* AM/PM Column (if 12-hour format) */} + {is12Hour && ( +
+
AM/PM
+
+ {['AM', 'PM'].map((period, optionIndex) => { + const isSelected = timeValue.period === period; + const columnIndex = includesSeconds ? 3 : 2; // AM/PM is last column + const isFocused = + dropdownFocus.isActive && + dropdownFocus.column === columnIndex && + dropdownFocus.option === optionIndex; + + return ( + + ); + })} +
+
+ )} +
+
+
+
+ ); +}; diff --git a/src/app-components/TimePicker/TimeSegment/TimeSegment.test.tsx b/src/app-components/TimePicker/TimeSegment/TimeSegment.test.tsx new file mode 100644 index 0000000000..6318259bee --- /dev/null +++ b/src/app-components/TimePicker/TimeSegment/TimeSegment.test.tsx @@ -0,0 +1,349 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { TimeSegment } from 'src/app-components/TimePicker/TimeSegment/TimeSegment'; +import { TimeSegmentProps } from 'src/app-components/TimePicker/types'; + +describe('TimeSegment Component', () => { + const defaultProps: TimeSegmentProps = { + value: 12, + min: 1, + max: 12, + type: 'hours', + format: 'hh:mm a', + onValueChange: jest.fn(), + onNavigate: jest.fn(), + 'aria-label': 'Hours', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render with formatted value', () => { + render( + , + ); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('09'); + }); + + it('should render period segment with AM/PM', () => { + render( + , + ); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('AM'); + }); + + it('should render with placeholder', () => { + render( + , + ); + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('placeholder', 'HH'); + }); + + it('should render as disabled when disabled prop is true', () => { + render( + , + ); + const input = screen.getByRole('textbox'); + expect(input).toBeDisabled(); + }); + + it('should render as readonly when readOnly prop is true', () => { + render( + , + ); + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('readonly'); + }); + }); + + describe('User Input', () => { + it('should accept valid numeric input', async () => { + const onValueChange = jest.fn(); + render( + , + ); + const input = screen.getByRole('textbox'); + + await userEvent.clear(input); + await userEvent.type(input, '8'); + + expect(onValueChange).toHaveBeenCalledWith(8); + }); + + it('should accept two-digit input', async () => { + const onValueChange = jest.fn(); + render( + , + ); + const input = screen.getByRole('textbox'); + + await userEvent.clear(input); + await userEvent.type(input, '11'); + + expect(onValueChange).toHaveBeenCalledWith(11); + }); + + it('should reject invalid input', async () => { + const onValueChange = jest.fn(); + render( + , + ); + const input = screen.getByRole('textbox'); + + await userEvent.clear(input); + await userEvent.type(input, 'abc'); + + expect(input).toHaveValue('--'); // Should show placeholder on invalid input + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('should accept period input', async () => { + const onValueChange = jest.fn(); + render( + , + ); + const input = screen.getByRole('textbox'); + + await userEvent.clear(input); + await userEvent.type(input, 'P'); + + // Trigger blur to commit the buffer + await userEvent.tab(); + + expect(onValueChange).toHaveBeenCalledWith('PM'); + }); + }); + + describe('Keyboard Navigation', () => { + it('should call onNavigate with right on ArrowRight', async () => { + const onNavigate = jest.fn(); + render( + , + ); + const input = screen.getByRole('textbox'); + + await userEvent.click(input); + await userEvent.keyboard('{ArrowRight}'); + + expect(onNavigate).toHaveBeenCalledWith('right'); + }); + + it('should call onNavigate with left on ArrowLeft', async () => { + const onNavigate = jest.fn(); + render( + , + ); + const input = screen.getByRole('textbox'); + + await userEvent.click(input); + await userEvent.keyboard('{ArrowLeft}'); + + expect(onNavigate).toHaveBeenCalledWith('left'); + }); + + it('should increment value on ArrowUp', async () => { + const onValueChange = jest.fn(); + render( + , + ); + const input = screen.getByRole('textbox'); + + await userEvent.click(input); + await userEvent.keyboard('{ArrowUp}'); + + expect(onValueChange).toHaveBeenCalledWith(9); + }); + + it('should decrement value on ArrowDown', async () => { + const onValueChange = jest.fn(); + render( + , + ); + const input = screen.getByRole('textbox'); + + await userEvent.click(input); + await userEvent.keyboard('{ArrowDown}'); + + expect(onValueChange).toHaveBeenCalledWith(7); + }); + + it('should toggle period on ArrowUp/Down', async () => { + const onValueChange = jest.fn(); + render( + , + ); + const input = screen.getByRole('textbox'); + + await userEvent.click(input); + await userEvent.keyboard('{ArrowUp}'); + expect(onValueChange).toHaveBeenCalledWith('PM'); + + jest.clearAllMocks(); + + // Simulate component with PM value for ArrowDown test + render( + , + ); + const pmInput = screen.getAllByRole('textbox')[1]; // Get the second input (PM one) + + await userEvent.click(pmInput); + await userEvent.keyboard('{ArrowDown}'); + expect(onValueChange).toHaveBeenCalledWith('AM'); + }); + }); + + describe('Focus Behavior', () => { + it('should handle focus events', async () => { + render( + , + ); + const input = screen.getByRole('textbox') as HTMLInputElement; + + await userEvent.click(input); + + // Just verify the input can receive focus + expect(document.activeElement).toBe(input); + // Note: Testing text selection is limited in jsdom + }); + + it('should auto-pad single digit on blur', async () => { + const onValueChange = jest.fn(); + const { rerender } = render( + , + ); + const input = screen.getByRole('textbox'); + + await userEvent.clear(input); + await userEvent.type(input, '3'); + await userEvent.tab(); // Trigger blur + + expect(onValueChange).toHaveBeenCalledWith(3); + + // Rerender with new value to check formatting + rerender( + , + ); + expect(input).toHaveValue('03'); + }); + + describe('Value Synchronization', () => { + it('should update display when value prop changes', () => { + const { rerender } = render( + , + ); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('08'); + + rerender( + , + ); + expect(input).toHaveValue('12'); + }); + + it('should format value based on segment type', () => { + render( + , + ); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('05'); + }); + + it('should handle 24-hour format', () => { + render( + , + ); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('14'); + }); + }); + }); +}); diff --git a/src/app-components/TimePicker/TimeSegment/TimeSegment.tsx b/src/app-components/TimePicker/TimeSegment/TimeSegment.tsx new file mode 100644 index 0000000000..246b011a8f --- /dev/null +++ b/src/app-components/TimePicker/TimeSegment/TimeSegment.tsx @@ -0,0 +1,137 @@ +import React from 'react'; + +import { Textfield } from '@digdir/designsystemet-react'; + +import { useSegmentDisplay } from 'src/app-components/TimePicker/TimeSegment/hooks/useSegmentDisplay'; +import { useSegmentInputHandlers } from 'src/app-components/TimePicker/TimeSegment/hooks/useSegmentInputHandlers'; +import { useTypingBuffer } from 'src/app-components/TimePicker/TimeSegment/hooks/useTypingBuffer'; +import type { TimeSegmentProps } from 'src/app-components/TimePicker/types'; + +export const TimeSegment = React.forwardRef( + ( + { + value, + type, + format, + onValueChange, + onNavigate, + placeholder, + disabled, + readOnly, + required, + 'aria-label': ariaLabel, + 'aria-describedby': ariaDescribedBy, + className, + }, + ref, + ) => { + const { displayValue, updateDisplayFromBuffer, syncWithExternalValue } = useSegmentDisplay(value, type, format); + + const inputHandlers = useSegmentInputHandlers({ + segmentType: type, + timeFormat: format, + currentValue: value, + onValueChange, + onNavigate, + onUpdateDisplay: updateDisplayFromBuffer, + }); + + const typingBuffer = useTypingBuffer({ + onCommit: inputHandlers.commitBufferValue, + commitDelayMs: 1000, + typingEndDelayMs: 2000, + }); + + const syncExternalChangesWhenNotTyping = () => { + if (!typingBuffer.isTyping) { + syncWithExternalValue(); + typingBuffer.resetToIdleState(); + } + }; + + React.useEffect(syncExternalChangesWhenNotTyping, [value, type, format, syncWithExternalValue, typingBuffer]); + + const handleCharacterTyping = (event: React.KeyboardEvent) => { + const character = event.key; + + // First check if it's a special key + const isSpecialKey = + character === 'Delete' || + character === 'Backspace' || + character === 'ArrowLeft' || + character === 'ArrowRight' || + character === 'ArrowUp' || + character === 'ArrowDown'; + + if (isSpecialKey) { + handleSpecialKeys(event); + return; + } + + // Handle regular character input + if (character.length === 1) { + event.preventDefault(); + + const currentBuffer = typingBuffer.buffer; + const inputResult = inputHandlers.processCharacterInput(character, currentBuffer); + + // Use the processed buffer result, not the raw character + typingBuffer.replaceBuffer(inputResult.newBuffer); + + if (inputResult.shouldNavigateRight) { + typingBuffer.commitImmediatelyAndEndTyping(); + onNavigate('right'); + } + } + }; + + const handleSpecialKeys = (event: React.KeyboardEvent) => { + const isDeleteOrBackspace = event.key === 'Delete' || event.key === 'Backspace'; + + if (isDeleteOrBackspace) { + event.preventDefault(); + inputHandlers.handleDeleteOrBackspace(); + typingBuffer.resetToIdleState(); + return; + } + + const wasArrowKeyHandled = inputHandlers.handleArrowKeyNavigation(event); + if (wasArrowKeyHandled) { + typingBuffer.commitImmediatelyAndEndTyping(); + } + }; + + const handleBlurEvent = () => { + typingBuffer.commitImmediatelyAndEndTyping(); + inputHandlers.fillEmptyMinutesOrSecondsWithZero(); + }; + + return ( + + ); + }, +); + +TimeSegment.displayName = 'TimeSegment'; diff --git a/src/app-components/TimePicker/TimeSegment/hooks/useSegmentDisplay.ts b/src/app-components/TimePicker/TimeSegment/hooks/useSegmentDisplay.ts new file mode 100644 index 0000000000..42b9f104f1 --- /dev/null +++ b/src/app-components/TimePicker/TimeSegment/hooks/useSegmentDisplay.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { formatSegmentValue } from 'src/app-components/TimePicker/utils/timeFormatUtils'; +import type { SegmentType, TimeFormat } from 'src/app-components/TimePicker/types'; + +export function useSegmentDisplay(externalValue: number | string, segmentType: SegmentType, timeFormat: TimeFormat) { + const [displayValue, setDisplayValue] = useState(() => formatSegmentValue(externalValue, segmentType, timeFormat)); + + const updateDisplayFromBuffer = useCallback((bufferValue: string) => { + setDisplayValue(bufferValue); + }, []); + + const syncWithExternalValue = useCallback(() => { + const formattedValue = formatSegmentValue(externalValue, segmentType, timeFormat); + setDisplayValue(formattedValue); + }, [externalValue, segmentType, timeFormat]); + + useEffect(() => { + syncWithExternalValue(); + }, [syncWithExternalValue]); + + return { + displayValue, + updateDisplayFromBuffer, + syncWithExternalValue, + }; +} diff --git a/src/app-components/TimePicker/TimeSegment/hooks/useSegmentInputHandlers.ts b/src/app-components/TimePicker/TimeSegment/hooks/useSegmentInputHandlers.ts new file mode 100644 index 0000000000..051e9ba882 --- /dev/null +++ b/src/app-components/TimePicker/TimeSegment/hooks/useSegmentInputHandlers.ts @@ -0,0 +1,116 @@ +import type React from 'react'; + +import { + handleSegmentKeyDown, + handleValueDecrement, + handleValueIncrement, +} from 'src/app-components/TimePicker/utils/keyboardNavigation'; +import { + clearSegment, + commitSegmentValue, + handleSegmentCharacterInput, + processSegmentBuffer, +} from 'src/app-components/TimePicker/utils/segmentTyping'; +import type { SegmentInputConfig } from 'src/app-components/TimePicker/types'; + +export function useSegmentInputHandlers({ + segmentType, + timeFormat, + currentValue, + onValueChange, + onNavigate, + onUpdateDisplay, +}: SegmentInputConfig) { + function incrementCurrentValue() { + const newValue = handleValueIncrement(currentValue, segmentType, timeFormat); + onValueChange(newValue); + } + + function decrementCurrentValue() { + const newValue = handleValueDecrement(currentValue, segmentType, timeFormat); + onValueChange(newValue); + } + + function clearCurrentValueAndDisplay() { + const clearedSegment = clearSegment(); + onUpdateDisplay(clearedSegment.displayValue); + if (segmentType === 'period') { + onValueChange(null); + } + const committedValue = commitSegmentValue(segmentType, clearedSegment.actualValue); + onValueChange(committedValue); + } + + function fillEmptyTimeSegmentWithZero() { + const valueIsEmpty = + currentValue === null || currentValue === '' || (typeof currentValue === 'number' && isNaN(currentValue)); + + if (valueIsEmpty && (segmentType === 'minutes' || segmentType === 'seconds')) { + onValueChange(0); + } + } + + function processCharacterInput(character: string, currentBuffer: string) { + const inputResult = handleSegmentCharacterInput(character, segmentType, currentBuffer, timeFormat); + const bufferResult = processSegmentBuffer(inputResult.newBuffer, segmentType, timeFormat.includes('a')); + + onUpdateDisplay(bufferResult.displayValue); + + return { + newBuffer: inputResult.newBuffer, + shouldNavigateRight: inputResult.shouldNavigate || inputResult.shouldAdvance, + shouldCommitImmediately: inputResult.shouldAdvance, + processedValue: bufferResult.actualValue, + }; + } + + function commitBufferValue(bufferValue: string) { + if (segmentType === 'period') { + onValueChange(bufferValue); + return; + } + + const processed = processSegmentBuffer(bufferValue, segmentType, timeFormat.includes('a')); + if (processed.actualValue !== null) { + const committedValue = commitSegmentValue(segmentType, processed.actualValue); + onValueChange(committedValue); + } + } + + function handleArrowKeyNavigation(event: React.KeyboardEvent) { + const result = handleSegmentKeyDown(event); + + if (result.shouldNavigate && result.direction) { + onNavigate(result.direction); + return true; + } + + if (result.shouldIncrement) { + incrementCurrentValue(); + return true; + } + + if (result.shouldDecrement) { + decrementCurrentValue(); + return true; + } + + return false; + } + + function handleDeleteOrBackspace() { + clearCurrentValueAndDisplay(); + } + + function fillEmptyMinutesOrSecondsWithZero() { + fillEmptyTimeSegmentWithZero(); + } + + return { + processCharacterInput, + commitBufferValue, + handleArrowKeyNavigation, + handleDeleteOrBackspace, + fillEmptyMinutesOrSecondsWithZero, + }; +} diff --git a/src/app-components/TimePicker/TimeSegment/hooks/useTimeout.ts b/src/app-components/TimePicker/TimeSegment/hooks/useTimeout.ts new file mode 100644 index 0000000000..053ae4266f --- /dev/null +++ b/src/app-components/TimePicker/TimeSegment/hooks/useTimeout.ts @@ -0,0 +1,35 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export function useTimeout(callback: () => void, delayMs: number) { + const timeoutRef = useRef | null>(null); + const savedCallback = useRef(callback); + + // Keep callback fresh to avoid stale closures + useEffect(() => { + savedCallback.current = callback; + }); + + const clear = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + const start = useCallback(() => { + clear(); + timeoutRef.current = setTimeout(() => { + savedCallback.current(); + }, delayMs); + }, [clear, delayMs]); + + // Clean up on unmount + useEffect(() => clear, [clear]); + + // Return stable object reference + const stableControls = useRef({ start, clear }); + stableControls.current.start = start; + stableControls.current.clear = clear; + + return stableControls.current; +} diff --git a/src/app-components/TimePicker/TimeSegment/hooks/useTypingBuffer.ts b/src/app-components/TimePicker/TimeSegment/hooks/useTypingBuffer.ts new file mode 100644 index 0000000000..6afb77be43 --- /dev/null +++ b/src/app-components/TimePicker/TimeSegment/hooks/useTypingBuffer.ts @@ -0,0 +1,75 @@ +import { useRef, useState } from 'react'; + +import { useTimeout } from 'src/app-components/TimePicker/TimeSegment/hooks/useTimeout'; +import type { TypingBufferConfig } from 'src/app-components/TimePicker/types'; + +export function useTypingBuffer({ onCommit, commitDelayMs, typingEndDelayMs }: TypingBufferConfig) { + const [buffer, setBuffer] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const bufferRef = useRef(''); + + const commitTimer = useTimeout(() => commitBufferAndClearIt(), commitDelayMs); + const typingEndTimer = useTimeout(() => setIsTyping(false), typingEndDelayMs); + + function clearBufferCompletely() { + setBuffer(''); + bufferRef.current = ''; + } + + function commitBufferAndClearIt() { + const currentBuffer = bufferRef.current; + if (currentBuffer) { + onCommit(currentBuffer); + clearBufferCompletely(); + } + } + + function stopAllTimers() { + commitTimer.clear(); + typingEndTimer.clear(); + } + + function startBothTimersAfterClearing() { + stopAllTimers(); + commitTimer.start(); + typingEndTimer.start(); + } + + function updateBufferAndStartTyping(newBuffer: string) { + setBuffer(newBuffer); + bufferRef.current = newBuffer; + setIsTyping(true); + startBothTimersAfterClearing(); + } + + function addCharacterToBuffer(char: string) { + const newBuffer = bufferRef.current + char; + updateBufferAndStartTyping(newBuffer); + return newBuffer; + } + + function replaceBuffer(newBuffer: string) { + updateBufferAndStartTyping(newBuffer); + } + + function commitImmediatelyAndEndTyping() { + commitBufferAndClearIt(); + setIsTyping(false); + stopAllTimers(); + } + + function resetToIdleState() { + clearBufferCompletely(); + setIsTyping(false); + stopAllTimers(); + } + + return { + buffer, + isTyping, + addCharacterToBuffer, + replaceBuffer, + commitImmediatelyAndEndTyping, + resetToIdleState, + }; +} diff --git a/src/app-components/TimePicker/types.ts b/src/app-components/TimePicker/types.ts new file mode 100644 index 0000000000..458bc4baa2 --- /dev/null +++ b/src/app-components/TimePicker/types.ts @@ -0,0 +1,122 @@ +// Time format types +export type TimeFormat = 'HH:mm' | 'HH:mm:ss' | 'hh:mm a' | 'hh:mm:ss a'; + +// Segment types +export type SegmentType = 'hours' | 'minutes' | 'seconds' | 'period'; +export type NumericSegmentType = Extract; + +// Core time value interface +export interface TimeValue { + hours: number; + minutes: number; + seconds: number; + period?: 'AM' | 'PM'; +} + +// Time constraints +export interface TimeConstraints { + minTime?: string; + maxTime?: string; +} + +export interface SegmentConstraints { + min: number; + max: number; + validValues: number[]; +} + +// Component props +export interface TimePickerProps { + id: string; + value: string; + onChange: (time: string) => void; + format?: TimeFormat; + minTime?: string; + maxTime?: string; + disabled?: boolean; + readOnly?: boolean; + labels?: { + hours?: string; + minutes?: string; + seconds?: string; + amPm?: string; + }; +} + +export interface TimeSegmentProps { + value: number | string; + min: number; + max: number; + type: SegmentType; + format: TimeFormat; + onValueChange: (value: number | string) => void; + onNavigate: (direction: 'left' | 'right') => void; + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + required?: boolean; + 'aria-label': string; + 'aria-describedby'?: string; + className?: string; + autoFocus?: boolean; +} + +// Dropdown and navigation +export interface DropdownFocusState { + column: number; + option: number; + isActive: boolean; +} + +export type NavigationAction = + | { type: 'ARROW_UP' } + | { type: 'ARROW_DOWN' } + | { type: 'ARROW_LEFT' } + | { type: 'ARROW_RIGHT' } + | { type: 'ENTER' } + | { type: 'ESCAPE' }; + +export interface TimeOption { + value: number; + label: string; +} + +// Segment typing and validation +export interface SegmentTypingResult { + value: string; + shouldAdvance: boolean; +} + +export interface SegmentBuffer { + displayValue: string; + actualValue: number | string | null; + isComplete: boolean; +} + +export interface SegmentNavigationResult { + shouldNavigate: boolean; + direction?: 'left' | 'right'; + shouldIncrement?: boolean; + shouldDecrement?: boolean; + preventDefault: boolean; +} + +export interface SegmentChangeResult { + updatedTimeValue: Partial; +} + +// Hook configurations +export interface SegmentInputConfig { + segmentType: SegmentType; + timeFormat: TimeFormat; + currentValue: number | string; + onValueChange: (value: number | string | null) => void; + onNavigate: (direction: 'left' | 'right') => void; + onUpdateDisplay: (value: string) => void; +} + +export interface TypingBufferConfig { + onCommit: (buffer: string) => void; + commitDelayMs: number; + typingEndDelayMs: number; +} diff --git a/src/app-components/TimePicker/utils/calculateNextFocusState/calculateNextFocusState.test.ts b/src/app-components/TimePicker/utils/calculateNextFocusState/calculateNextFocusState.test.ts new file mode 100644 index 0000000000..489d38c511 --- /dev/null +++ b/src/app-components/TimePicker/utils/calculateNextFocusState/calculateNextFocusState.test.ts @@ -0,0 +1,219 @@ +import { DropdownFocusState, NavigationAction } from 'src/app-components/TimePicker/types'; +import { calculateNextFocusState } from 'src/app-components/TimePicker/utils/calculateNextFocusState/calculateNextFocusState'; + +describe('calculateNextFocusState', () => { + const maxColumns = 3; // hours, minutes, seconds + const optionCounts = [24, 60, 60]; // 24 hours, 60 minutes, 60 seconds + + describe('inactive state', () => { + it('should return unchanged state when not active', () => { + const inactiveState: DropdownFocusState = { + column: 0, + option: 5, + isActive: false, + }; + + const action: NavigationAction = { type: 'ARROW_DOWN' }; + const result = calculateNextFocusState(inactiveState, action, maxColumns, optionCounts); + + expect(result).toEqual(inactiveState); + }); + }); + + describe('ARROW_DOWN navigation', () => { + it('should increment option index', () => { + const state: DropdownFocusState = { column: 0, option: 5, isActive: true }; + const action: NavigationAction = { type: 'ARROW_DOWN' }; + + const result = calculateNextFocusState(state, action, maxColumns, optionCounts); + + expect(result).toEqual({ + column: 0, + option: 6, + isActive: true, + }); + }); + + it('should wrap to 0 when at last option', () => { + const state: DropdownFocusState = { column: 0, option: 23, isActive: true }; // Last hour + const action: NavigationAction = { type: 'ARROW_DOWN' }; + + const result = calculateNextFocusState(state, action, maxColumns, optionCounts); + + expect(result).toEqual({ + column: 0, + option: 0, + isActive: true, + }); + }); + + it('should handle different column option counts', () => { + // Minutes column (60 options) + const state: DropdownFocusState = { column: 1, option: 59, isActive: true }; + const action: NavigationAction = { type: 'ARROW_DOWN' }; + + const result = calculateNextFocusState(state, action, maxColumns, optionCounts); + + expect(result).toEqual({ + column: 1, + option: 0, + isActive: true, + }); + }); + }); + + describe('ARROW_UP navigation', () => { + it('should decrement option index', () => { + const state: DropdownFocusState = { column: 0, option: 5, isActive: true }; + const action: NavigationAction = { type: 'ARROW_UP' }; + + const result = calculateNextFocusState(state, action, maxColumns, optionCounts); + + expect(result).toEqual({ + column: 0, + option: 4, + isActive: true, + }); + }); + + it('should wrap to last option when at 0', () => { + const state: DropdownFocusState = { column: 0, option: 0, isActive: true }; + const action: NavigationAction = { type: 'ARROW_UP' }; + + const result = calculateNextFocusState(state, action, maxColumns, optionCounts); + + expect(result).toEqual({ + column: 0, + option: 23, // Last hour (24-1) + isActive: true, + }); + }); + }); + + describe('ARROW_RIGHT navigation', () => { + it('should move to next column', () => { + const state: DropdownFocusState = { column: 0, option: 5, isActive: true }; + const action: NavigationAction = { type: 'ARROW_RIGHT' }; + + const result = calculateNextFocusState(state, action, maxColumns, optionCounts); + + expect(result).toEqual({ + column: 1, + option: 5, // Same option index if valid + isActive: true, + }); + }); + + it('should wrap to first column when at last column', () => { + const state: DropdownFocusState = { column: 2, option: 10, isActive: true }; // Seconds column + const action: NavigationAction = { type: 'ARROW_RIGHT' }; + + const result = calculateNextFocusState(state, action, maxColumns, optionCounts); + + expect(result).toEqual({ + column: 0, // Wrap to hours + option: 10, + isActive: true, + }); + }); + + it('should adjust option index if target column has fewer options', () => { + const customOptionCounts = [24, 12, 60]; // Hours, limited minutes, seconds + const state: DropdownFocusState = { column: 0, option: 20, isActive: true }; // Hour 20 + const action: NavigationAction = { type: 'ARROW_RIGHT' }; + + const result = calculateNextFocusState(state, action, maxColumns, customOptionCounts); + + expect(result).toEqual({ + column: 1, + option: 11, // Adjusted to last minute option (12-1) + isActive: true, + }); + }); + }); + + describe('ARROW_LEFT navigation', () => { + it('should move to previous column', () => { + const state: DropdownFocusState = { column: 1, option: 5, isActive: true }; + const action: NavigationAction = { type: 'ARROW_LEFT' }; + + const result = calculateNextFocusState(state, action, maxColumns, optionCounts); + + expect(result).toEqual({ + column: 0, + option: 5, + isActive: true, + }); + }); + + it('should wrap to last column when at first column', () => { + const state: DropdownFocusState = { column: 0, option: 5, isActive: true }; + const action: NavigationAction = { type: 'ARROW_LEFT' }; + + const result = calculateNextFocusState(state, action, maxColumns, optionCounts); + + expect(result).toEqual({ + column: 2, // Wrap to seconds column + option: 5, + isActive: true, + }); + }); + }); + + describe('ESCAPE and ENTER navigation', () => { + it('should deactivate focus state on ESCAPE', () => { + const state: DropdownFocusState = { column: 1, option: 10, isActive: true }; + const action: NavigationAction = { type: 'ESCAPE' }; + + const result = calculateNextFocusState(state, action, maxColumns, optionCounts); + + expect(result).toEqual({ + column: 0, + option: -1, + isActive: false, + }); + }); + + it('should deactivate focus state on ENTER', () => { + const state: DropdownFocusState = { column: 2, option: 30, isActive: true }; + const action: NavigationAction = { type: 'ENTER' }; + + const result = calculateNextFocusState(state, action, maxColumns, optionCounts); + + expect(result).toEqual({ + column: 0, + option: -1, + isActive: false, + }); + }); + }); + + describe('edge cases', () => { + it('should handle empty option counts array', () => { + const state: DropdownFocusState = { column: 0, option: 0, isActive: true }; + const action: NavigationAction = { type: 'ARROW_DOWN' }; + const emptyOptionCounts: number[] = []; + + const result = calculateNextFocusState(state, action, maxColumns, emptyOptionCounts); + + expect(result).toEqual({ + column: 0, + option: 0, // Falls back to 1 option, so (0 + 1) % 1 = 0 + isActive: true, + }); + }); + + it('should handle single column navigation', () => { + const singleMaxColumns = 1; + const singleOptionCounts = [24]; + const state: DropdownFocusState = { column: 0, option: 10, isActive: true }; + + // Left/right should stay in same column + const rightResult = calculateNextFocusState(state, { type: 'ARROW_RIGHT' }, singleMaxColumns, singleOptionCounts); + const leftResult = calculateNextFocusState(state, { type: 'ARROW_LEFT' }, singleMaxColumns, singleOptionCounts); + + expect(rightResult.column).toBe(0); + expect(leftResult.column).toBe(0); + }); + }); +}); diff --git a/src/app-components/TimePicker/utils/calculateNextFocusState/calculateNextFocusState.ts b/src/app-components/TimePicker/utils/calculateNextFocusState/calculateNextFocusState.ts new file mode 100644 index 0000000000..3afe62d0f4 --- /dev/null +++ b/src/app-components/TimePicker/utils/calculateNextFocusState/calculateNextFocusState.ts @@ -0,0 +1,68 @@ +import type { DropdownFocusState, NavigationAction } from 'src/app-components/TimePicker/types'; + +/** + * Calculates the next focus state based on the current state and navigation action + * @param current - Current dropdown focus state + * @param action - Navigation action to perform + * @param maxColumns - Maximum number of columns in the dropdown + * @param optionCounts - Array of option counts for each column + * @returns New dropdown focus state + */ +export const calculateNextFocusState = ( + current: DropdownFocusState, + action: NavigationAction, + maxColumns: number, + optionCounts: number[], +): DropdownFocusState => { + if (!current.isActive) { + return current; + } + + switch (action.type) { + case 'ARROW_DOWN': { + const currentColumnOptions = optionCounts[current.column] || 1; + return { + ...current, + option: (current.option + 1) % currentColumnOptions, + }; + } + + case 'ARROW_UP': { + const currentColumnOptions = optionCounts[current.column] || 1; + return { + ...current, + option: (current.option - 1 + currentColumnOptions) % currentColumnOptions, + }; + } + + case 'ARROW_RIGHT': { + const newColumn = (current.column + 1) % maxColumns; + return { + column: newColumn, + option: Math.min(current.option, (optionCounts[newColumn] || 1) - 1), + isActive: true, + }; + } + + case 'ARROW_LEFT': { + const newColumn = (current.column - 1 + maxColumns) % maxColumns; + return { + column: newColumn, + option: Math.min(current.option, (optionCounts[newColumn] || 1) - 1), + isActive: true, + }; + } + + case 'ESCAPE': + case 'ENTER': { + return { + column: 0, + option: -1, + isActive: false, + }; + } + + default: + return current; + } +}; diff --git a/src/app-components/TimePicker/utils/formatDisplayHour/formatDisplayHour.test.ts b/src/app-components/TimePicker/utils/formatDisplayHour/formatDisplayHour.test.ts new file mode 100644 index 0000000000..6f16561a9e --- /dev/null +++ b/src/app-components/TimePicker/utils/formatDisplayHour/formatDisplayHour.test.ts @@ -0,0 +1,69 @@ +import { formatDisplayHour } from 'src/app-components/TimePicker/utils/formatDisplayHour/formatDisplayHour'; + +describe('formatDisplayHour', () => { + describe('24-hour format', () => { + it('should return hour unchanged for 24-hour format', () => { + expect(formatDisplayHour(0, false)).toBe(0); + expect(formatDisplayHour(1, false)).toBe(1); + expect(formatDisplayHour(12, false)).toBe(12); + expect(formatDisplayHour(13, false)).toBe(13); + expect(formatDisplayHour(23, false)).toBe(23); + }); + }); + + describe('12-hour format', () => { + it('should convert midnight (0) to 12', () => { + expect(formatDisplayHour(0, true)).toBe(12); + }); + + it('should keep AM hours (1-12) unchanged', () => { + expect(formatDisplayHour(1, true)).toBe(1); + expect(formatDisplayHour(11, true)).toBe(11); + expect(formatDisplayHour(12, true)).toBe(12); // Noon stays 12 + }); + + it('should convert PM hours (13-23) to 1-11', () => { + expect(formatDisplayHour(13, true)).toBe(1); + expect(formatDisplayHour(14, true)).toBe(2); + expect(formatDisplayHour(18, true)).toBe(6); + expect(formatDisplayHour(23, true)).toBe(11); + }); + }); + + describe('edge cases', () => { + it('should handle boundary values correctly', () => { + // Midnight + expect(formatDisplayHour(0, true)).toBe(12); + expect(formatDisplayHour(0, false)).toBe(0); + + // Noon + expect(formatDisplayHour(12, true)).toBe(12); + expect(formatDisplayHour(12, false)).toBe(12); + + // 1 PM + expect(formatDisplayHour(13, true)).toBe(1); + expect(formatDisplayHour(13, false)).toBe(13); + + // 11 PM + expect(formatDisplayHour(23, true)).toBe(11); + expect(formatDisplayHour(23, false)).toBe(23); + }); + }); + + describe('comprehensive 12-hour conversion table', () => { + const conversions = [ + { input: 0, expected: 12 }, // 12:xx AM (midnight) + { input: 1, expected: 1 }, // 1:xx AM + { input: 11, expected: 11 }, // 11:xx AM + { input: 12, expected: 12 }, // 12:xx PM (noon) + { input: 13, expected: 1 }, // 1:xx PM + { input: 14, expected: 2 }, // 2:xx PM + { input: 18, expected: 6 }, // 6:xx PM + { input: 23, expected: 11 }, // 11:xx PM + ]; + + it.each(conversions)('should convert hour $input to $expected in 12-hour format', ({ input, expected }) => { + expect(formatDisplayHour(input, true)).toBe(expected); + }); + }); +}); diff --git a/src/app-components/TimePicker/utils/formatDisplayHour/formatDisplayHour.ts b/src/app-components/TimePicker/utils/formatDisplayHour/formatDisplayHour.ts new file mode 100644 index 0000000000..6e6e622d39 --- /dev/null +++ b/src/app-components/TimePicker/utils/formatDisplayHour/formatDisplayHour.ts @@ -0,0 +1,22 @@ +/** + * Formats an hour value for display based on the time format + * @param hour - The hour value (0-23) + * @param is12Hour - Whether to use 12-hour format display + * @returns The formatted hour value for display + */ +export const formatDisplayHour = (hour: number, is12Hour: boolean): number => { + if (!is12Hour) { + return hour; + } + + // Convert 24-hour to 12-hour format + if (hour === 0) { + return 12; // Midnight (00:xx) -> 12:xx AM + } + + if (hour > 12) { + return hour - 12; // PM hours (13-23) -> 1-11 PM + } + + return hour; // AM hours (1-12) stay the same +}; diff --git a/src/app-components/TimePicker/utils/generateTimeOptions/generateTimeOptions.test.ts b/src/app-components/TimePicker/utils/generateTimeOptions/generateTimeOptions.test.ts new file mode 100644 index 0000000000..bf5fc3093e --- /dev/null +++ b/src/app-components/TimePicker/utils/generateTimeOptions/generateTimeOptions.test.ts @@ -0,0 +1,113 @@ +import { + generateHourOptions, + generateMinuteOptions, + generateSecondOptions, +} from 'src/app-components/TimePicker/utils/generateTimeOptions/generateTimeOptions'; + +describe('generateTimeOptions', () => { + describe('generateHourOptions', () => { + describe('24-hour format', () => { + it('should generate 24 options from 00 to 23', () => { + const options = generateHourOptions(false); + + expect(options).toHaveLength(24); + expect(options[0]).toEqual({ value: 0, label: '00' }); + expect(options[12]).toEqual({ value: 12, label: '12' }); + expect(options[23]).toEqual({ value: 23, label: '23' }); + }); + + it('should pad single digits with zero', () => { + const options = generateHourOptions(false); + + expect(options[1].label).toBe('01'); + expect(options[9].label).toBe('09'); + expect(options[10].label).toBe('10'); + }); + }); + + describe('12-hour format', () => { + it('should generate 12 options from 01 to 12', () => { + const options = generateHourOptions(true); + + expect(options).toHaveLength(12); + expect(options[0]).toEqual({ value: 1, label: '01' }); + expect(options[11]).toEqual({ value: 12, label: '12' }); + }); + + it('should not include 00 or values above 12', () => { + const options = generateHourOptions(true); + + const values = options.map((o) => o.value); + expect(values).not.toContain(0); + expect(values).not.toContain(13); + expect(Math.max(...(values as number[]))).toBe(12); + expect(Math.min(...(values as number[]))).toBe(1); + }); + }); + }); + + describe('generateMinuteOptions', () => { + it('should generate 60 options by default (step=1)', () => { + const options = generateMinuteOptions(); + + expect(options).toHaveLength(60); + expect(options[0]).toEqual({ value: 0, label: '00' }); + expect(options[30]).toEqual({ value: 30, label: '30' }); + expect(options[59]).toEqual({ value: 59, label: '59' }); + }); + + it('should generate correct number of options for step=5', () => { + const options = generateMinuteOptions(5); + + expect(options).toHaveLength(12); // 60 / 5 = 12 + expect(options[0]).toEqual({ value: 0, label: '00' }); + expect(options[1]).toEqual({ value: 5, label: '05' }); + expect(options[11]).toEqual({ value: 55, label: '55' }); + }); + + it('should generate correct number of options for step=15', () => { + const options = generateMinuteOptions(15); + + expect(options).toHaveLength(4); // 60 / 15 = 4 + expect(options[0]).toEqual({ value: 0, label: '00' }); + expect(options[1]).toEqual({ value: 15, label: '15' }); + expect(options[2]).toEqual({ value: 30, label: '30' }); + expect(options[3]).toEqual({ value: 45, label: '45' }); + }); + + it('should pad single digits with zero', () => { + const options = generateMinuteOptions(1); + + expect(options[5].label).toBe('05'); + expect(options[9].label).toBe('09'); + expect(options[10].label).toBe('10'); + }); + }); + + describe('generateSecondOptions', () => { + it('should generate 60 options by default (step=1)', () => { + const options = generateSecondOptions(); + + expect(options).toHaveLength(60); + expect(options[0]).toEqual({ value: 0, label: '00' }); + expect(options[30]).toEqual({ value: 30, label: '30' }); + expect(options[59]).toEqual({ value: 59, label: '59' }); + }); + + it('should generate correct number of options for step=5', () => { + const options = generateSecondOptions(5); + + expect(options).toHaveLength(12); // 60 / 5 = 12 + expect(options[0]).toEqual({ value: 0, label: '00' }); + expect(options[1]).toEqual({ value: 5, label: '05' }); + expect(options[11]).toEqual({ value: 55, label: '55' }); + }); + + it('should behave identically to generateMinuteOptions', () => { + const minuteOptions = generateMinuteOptions(10); + const secondOptions = generateSecondOptions(10); + + expect(secondOptions).toEqual(minuteOptions); + }); + }); +}); diff --git a/src/app-components/TimePicker/utils/generateTimeOptions/generateTimeOptions.ts b/src/app-components/TimePicker/utils/generateTimeOptions/generateTimeOptions.ts new file mode 100644 index 0000000000..b90ec67899 --- /dev/null +++ b/src/app-components/TimePicker/utils/generateTimeOptions/generateTimeOptions.ts @@ -0,0 +1,44 @@ +import type { TimeOption } from 'src/app-components/TimePicker/types'; + +/** + * Generates hour options for the timepicker dropdown + * @param is12Hour - Whether to use 12-hour format (1-12) or 24-hour format (0-23) + * @returns Array of hour options with value and label + */ +export const generateHourOptions = (is12Hour: boolean): TimeOption[] => { + if (is12Hour) { + return Array.from({ length: 12 }, (_, i) => ({ + value: i + 1, + label: (i + 1).toString().padStart(2, '0'), + })); + } + + return Array.from({ length: 24 }, (_, i) => ({ + value: i, + label: i.toString().padStart(2, '0'), + })); +}; + +/** + * Generates second options for the timepicker dropdown + * @param step - Step increment for seconds (default: 1, common values: 1, 5, 15, 30) + * @returns Array of second options with value and label + */ +export const generateSecondOptions = (step: number = 1): TimeOption[] => { + const count = Math.floor(60 / step); + + return Array.from({ length: count }, (_, i) => { + const value = i * step; + return { + value, + label: value.toString().padStart(2, '0'), + }; + }); +}; + +/** + * Generates minute options for the timepicker dropdown + * @param step - Step increment for minutes (default: 1, common values: 1, 5, 15, 30) + * @returns Array of minute options with value and label + */ +export const generateMinuteOptions = (step: number = 1): TimeOption[] => generateSecondOptions(step); diff --git a/src/app-components/TimePicker/utils/handleSegmentValueChange/handleSegmentValueChange.test.ts b/src/app-components/TimePicker/utils/handleSegmentValueChange/handleSegmentValueChange.test.ts new file mode 100644 index 0000000000..b0e6878668 --- /dev/null +++ b/src/app-components/TimePicker/utils/handleSegmentValueChange/handleSegmentValueChange.test.ts @@ -0,0 +1,231 @@ +import { SegmentConstraints, TimeValue } from 'src/app-components/TimePicker/types'; +import { handleSegmentValueChange } from 'src/app-components/TimePicker/utils/handleSegmentValueChange/handleSegmentValueChange'; + +describe('handleSegmentValueChange', () => { + const mockTimeValue: TimeValue = { + hours: 14, + minutes: 30, + seconds: 45, + period: 'PM', + }; + + const mockConstraints: SegmentConstraints = { + min: 0, + max: 59, + validValues: [0, 15, 30, 45], // 15-minute intervals for testing + }; + + describe('period changes', () => { + it('should convert PM to AM by subtracting 12 from hours >= 12', () => { + const result = handleSegmentValueChange('period', 'AM', mockTimeValue, mockConstraints, true); + + expect(result.updatedTimeValue).toEqual({ + period: 'AM', + hours: 2, // 14 - 12 = 2 + }); + }); + + it('should convert AM to PM by adding 12 to hours < 12', () => { + const timeValue: TimeValue = { hours: 10, minutes: 30, seconds: 45, period: 'AM' }; + + const result = handleSegmentValueChange('period', 'PM', timeValue, mockConstraints, true); + + expect(result.updatedTimeValue).toEqual({ + period: 'PM', + hours: 22, // 10 + 12 = 22 + }); + }); + + it('should not change hours when converting PM to AM for hours < 12', () => { + const timeValue: TimeValue = { hours: 2, minutes: 30, seconds: 45, period: 'PM' }; + + const result = handleSegmentValueChange('period', 'AM', timeValue, mockConstraints, true); + + expect(result.updatedTimeValue).toEqual({ + period: 'AM', + hours: 2, // No change needed + }); + }); + + it('should not change hours when converting AM to PM for hours >= 12', () => { + const timeValue: TimeValue = { hours: 15, minutes: 30, seconds: 45, period: 'AM' }; + + const result = handleSegmentValueChange('period', 'PM', timeValue, mockConstraints, true); + + expect(result.updatedTimeValue).toEqual({ + period: 'PM', + hours: 15, // No change needed + }); + }); + }); + + describe('hours wrapping', () => { + const hoursConstraints: SegmentConstraints = { + min: 1, + max: 12, + validValues: Array.from({ length: 12 }, (_, i) => i + 1), // 1-12 + }; + + describe('12-hour format', () => { + it('should wrap hours > 12 to 1', () => { + const result = handleSegmentValueChange('hours', 15, mockTimeValue, hoursConstraints, true); + + expect(result.updatedTimeValue).toEqual({ hours: 1 }); + }); + + it('should wrap hours < 1 to 12', () => { + const result = handleSegmentValueChange('hours', 0, mockTimeValue, hoursConstraints, true); + + expect(result.updatedTimeValue).toEqual({ hours: 12 }); + }); + + it('should keep valid hours unchanged', () => { + const result = handleSegmentValueChange('hours', 8, mockTimeValue, hoursConstraints, true); + + expect(result.updatedTimeValue).toEqual({ hours: 8 }); + }); + }); + + describe('24-hour format', () => { + const hours24Constraints: SegmentConstraints = { + min: 0, + max: 23, + validValues: Array.from({ length: 24 }, (_, i) => i), // 0-23 + }; + + it('should wrap hours > 23 to 0', () => { + const result = handleSegmentValueChange('hours', 25, mockTimeValue, hours24Constraints, false); + + expect(result.updatedTimeValue).toEqual({ hours: 0 }); + }); + + it('should wrap hours < 0 to 23', () => { + const result = handleSegmentValueChange('hours', -1, mockTimeValue, hours24Constraints, false); + + expect(result.updatedTimeValue).toEqual({ hours: 23 }); + }); + + it('should keep valid hours unchanged', () => { + const result = handleSegmentValueChange('hours', 15, mockTimeValue, hours24Constraints, false); + + expect(result.updatedTimeValue).toEqual({ hours: 15 }); + }); + }); + }); + + describe('minutes wrapping', () => { + const minutesConstraints: SegmentConstraints = { + min: 0, + max: 59, + validValues: Array.from({ length: 60 }, (_, i) => i), // 0-59 + }; + + it('should wrap minutes > 59 to 0', () => { + const result = handleSegmentValueChange('minutes', 65, mockTimeValue, minutesConstraints, true); + + expect(result.updatedTimeValue).toEqual({ minutes: 0 }); + }); + + it('should wrap minutes < 0 to 59', () => { + const result = handleSegmentValueChange('minutes', -1, mockTimeValue, minutesConstraints, true); + + expect(result.updatedTimeValue).toEqual({ minutes: 59 }); + }); + + it('should keep valid minutes unchanged', () => { + const result = handleSegmentValueChange('minutes', 45, mockTimeValue, minutesConstraints, true); + + expect(result.updatedTimeValue).toEqual({ minutes: 45 }); + }); + }); + + describe('seconds wrapping', () => { + const secondsConstraints: SegmentConstraints = { + min: 0, + max: 59, + validValues: Array.from({ length: 60 }, (_, i) => i), // 0-59 + }; + + it('should wrap seconds > 59 to 0', () => { + const result = handleSegmentValueChange('seconds', 72, mockTimeValue, secondsConstraints, true); + + expect(result.updatedTimeValue).toEqual({ seconds: 0 }); + }); + + it('should wrap seconds < 0 to 59', () => { + const result = handleSegmentValueChange('seconds', -1, mockTimeValue, secondsConstraints, true); + + expect(result.updatedTimeValue).toEqual({ seconds: 59 }); + }); + + it('should keep valid seconds unchanged', () => { + const result = handleSegmentValueChange('seconds', 20, mockTimeValue, secondsConstraints, true); + + expect(result.updatedTimeValue).toEqual({ seconds: 20 }); + }); + }); + + describe('constraint validation', () => { + it('should find nearest valid value when wrapped value is not in constraints', () => { + const result = handleSegmentValueChange( + 'minutes', + 22, // Not in validValues [0, 15, 30, 45] + mockTimeValue, + mockConstraints, + true, + ); + + expect(result.updatedTimeValue).toEqual({ minutes: 15 }); // Nearest valid value + }); + + it('should find nearest valid value on the higher side', () => { + const result = handleSegmentValueChange( + 'minutes', + 37, // Closer to 30 than 45 + mockTimeValue, + mockConstraints, + true, + ); + + expect(result.updatedTimeValue).toEqual({ minutes: 30 }); + }); + + it('should find nearest valid value on the lower side', () => { + const result = handleSegmentValueChange( + 'minutes', + 38, // Closer to 45 than 30 + mockTimeValue, + mockConstraints, + true, + ); + + expect(result.updatedTimeValue).toEqual({ minutes: 45 }); + }); + }); + + describe('error handling', () => { + it('should throw error for invalid segment type and value type combination', () => { + expect(() => { + handleSegmentValueChange( + 'period', + 123, // number instead of string + mockTimeValue, + mockConstraints, + true, + ); + }).toThrow('Invalid combination: segmentType period with value type number'); + }); + + it('should throw error for numeric segment with string value', () => { + expect(() => { + handleSegmentValueChange( + 'hours', + 'invalid', // string instead of number + mockTimeValue, + mockConstraints, + true, + ); + }).toThrow('Invalid combination: segmentType hours with value type string'); + }); + }); +}); diff --git a/src/app-components/TimePicker/utils/handleSegmentValueChange/handleSegmentValueChange.ts b/src/app-components/TimePicker/utils/handleSegmentValueChange/handleSegmentValueChange.ts new file mode 100644 index 0000000000..83503afb85 --- /dev/null +++ b/src/app-components/TimePicker/utils/handleSegmentValueChange/handleSegmentValueChange.ts @@ -0,0 +1,136 @@ +import type { + NumericSegmentType, + SegmentChangeResult, + SegmentConstraints, + SegmentType, + TimeValue, +} from 'src/app-components/TimePicker/types'; + +/** + * Handles period (AM/PM) changes by adjusting hours accordingly + */ +const handlePeriodChange = (newPeriod: 'AM' | 'PM', currentTimeValue: TimeValue): SegmentChangeResult => { + let newHours = currentTimeValue.hours; + + if (newPeriod === 'PM' && currentTimeValue.hours < 12) { + newHours += 12; + } + + if (newPeriod === 'AM' && currentTimeValue.hours >= 12) { + newHours -= 12; + } + + return { + updatedTimeValue: { period: newPeriod, hours: newHours }, + }; +}; + +/** + * Wraps hour values based on 12/24 hour format + */ +const wrapHours = (value: number, is12Hour: boolean): number => { + if (is12Hour) { + if (value > 12) { + return 1; + } + if (value < 1) { + return 12; + } + return value; + } + + if (value > 23) { + return 0; + } + if (value < 0) { + return 23; + } + return value; +}; + +/** + * Wraps minutes/seconds values (0-59) + */ +const wrapMinutesSeconds = (value: number): number => { + if (value > 59) { + return 0; + } + if (value < 0) { + return 59; + } + return value; +}; + +/** + * Wraps numeric values within valid ranges for different segment types + */ +const wrapNumericValue = (value: number, segmentType: NumericSegmentType, is12Hour: boolean): number => { + switch (segmentType) { + case 'hours': + return wrapHours(value, is12Hour); + case 'minutes': + case 'seconds': + return wrapMinutesSeconds(value); + } +}; + +/** + * Finds the nearest valid value from constraints + */ +const findNearestValidValue = (targetValue: number, validValues: number[]): number => { + if (validValues.length === 0) { + return targetValue; + } + return validValues.reduce((prev, curr) => + Math.abs(curr - targetValue) < Math.abs(prev - targetValue) ? curr : prev, + ); +}; + +/** + * Handles numeric segment changes with validation and wrapping + */ +const handleNumericSegmentChange = ( + segmentType: NumericSegmentType, + value: number, + segmentConstraints: SegmentConstraints, + is12Hour: boolean, +): SegmentChangeResult => { + const wrappedValue = wrapNumericValue(value, segmentType, is12Hour); + + // Return wrapped value if it's within constraints + if (segmentConstraints.validValues.includes(wrappedValue)) { + return { + updatedTimeValue: { [segmentType]: wrappedValue }, + }; + } + + // Find and return nearest valid value + const nearestValid = findNearestValidValue(wrappedValue, segmentConstraints.validValues); + return { + updatedTimeValue: { [segmentType]: nearestValid }, + }; +}; + +/** + * Handles changes to time segments with proper validation and wrapping + */ +export const handleSegmentValueChange = ( + segmentType: SegmentType, + newValue: number | string, + currentTimeValue: TimeValue, + segmentConstraints: SegmentConstraints, + is12Hour: boolean, +): SegmentChangeResult => { + // Handle period changes + if (segmentType === 'period' && typeof newValue === 'string') { + return handlePeriodChange(newValue as 'AM' | 'PM', currentTimeValue); + } + + // Handle numeric segments + if (segmentType !== 'period' && typeof newValue === 'number') { + return handleNumericSegmentChange(segmentType, newValue, segmentConstraints, is12Hour); + } + + // Invalid combination - should not happen with proper typing + throw new Error(`Invalid combination: segmentType ${segmentType} with value type ${typeof newValue}`); +}; diff --git a/src/app-components/TimePicker/utils/keyboardNavigation.test.ts b/src/app-components/TimePicker/utils/keyboardNavigation.test.ts new file mode 100644 index 0000000000..da80a4308a --- /dev/null +++ b/src/app-components/TimePicker/utils/keyboardNavigation.test.ts @@ -0,0 +1,112 @@ +import { + getNextSegmentIndex, + handleSegmentKeyDown, + handleValueDecrement, + handleValueIncrement, +} from 'src/app-components/TimePicker/utils/keyboardNavigation'; + +interface MockKeyboardEvent { + key: string; + preventDefault: () => void; +} + +type SegmentType = 'hours' | 'minutes' | 'seconds' | 'period'; + +describe('Keyboard Navigation Logic', () => { + describe('handleSegmentKeyDown', () => { + it('should handle Arrow Up key', () => { + const mockEvent = { key: 'ArrowUp', preventDefault: jest.fn() } as unknown as MockKeyboardEvent; + const result = handleSegmentKeyDown(mockEvent); + + expect(result.shouldIncrement).toBe(true); + expect(result.preventDefault).toBe(true); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + }); + + it('should handle Arrow Down key', () => { + const mockEvent = { key: 'ArrowDown', preventDefault: jest.fn() } as unknown as MockKeyboardEvent; + const result = handleSegmentKeyDown(mockEvent); + + expect(result.shouldDecrement).toBe(true); + expect(result.preventDefault).toBe(true); + }); + + it('should handle Arrow Right key', () => { + const mockEvent = { key: 'ArrowRight', preventDefault: jest.fn() } as unknown as MockKeyboardEvent; + const result = handleSegmentKeyDown(mockEvent); + + expect(result.shouldNavigate).toBe(true); + expect(result.direction).toBe('right'); + expect(result.preventDefault).toBe(true); + }); + + it('should handle Arrow Left key', () => { + const mockEvent = { key: 'ArrowLeft', preventDefault: jest.fn() } as unknown as MockKeyboardEvent; + const result = handleSegmentKeyDown(mockEvent); + + expect(result.shouldNavigate).toBe(true); + expect(result.direction).toBe('left'); + expect(result.preventDefault).toBe(true); + }); + + it('should not handle other keys', () => { + const mockEvent = { key: 'Enter', preventDefault: jest.fn() } as unknown as MockKeyboardEvent; + const result = handleSegmentKeyDown(mockEvent); + + expect(result.shouldNavigate).toBe(false); + expect(result.shouldIncrement).toBe(false); + expect(result.shouldDecrement).toBe(false); + expect(result.preventDefault).toBe(false); + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + }); + }); + + describe('getNextSegmentIndex', () => { + const segments: SegmentType[] = ['hours', 'minutes', 'seconds', 'period']; + + it('should navigate between segments correctly', () => { + expect(getNextSegmentIndex(0, 'right', segments)).toBe(1); + expect(getNextSegmentIndex(1, 'left', segments)).toBe(0); + expect(getNextSegmentIndex(3, 'right', segments)).toBe(0); // wrap right + expect(getNextSegmentIndex(0, 'left', segments)).toBe(3); // wrap left + }); + }); + + describe('handleValueIncrement', () => { + it('should increment hours correctly', () => { + expect(handleValueIncrement(8, 'hours', 'HH:mm')).toBe(9); + expect(handleValueIncrement(23, 'hours', 'HH:mm')).toBe(0); // wrap 24h + expect(handleValueIncrement(12, 'hours', 'hh:mm a')).toBe(1); // wrap 12h + }); + + it('should increment minutes and seconds', () => { + expect(handleValueIncrement(30, 'minutes', 'HH:mm')).toBe(31); + expect(handleValueIncrement(59, 'minutes', 'HH:mm')).toBe(0); // wrap + expect(handleValueIncrement(59, 'seconds', 'HH:mm:ss')).toBe(0); // wrap + }); + + it('should toggle period', () => { + expect(handleValueIncrement('AM', 'period', 'hh:mm a')).toBe('PM'); + expect(handleValueIncrement('PM', 'period', 'hh:mm a')).toBe('AM'); + }); + }); + + describe('handleValueDecrement', () => { + it('should decrement hours correctly', () => { + expect(handleValueDecrement(8, 'hours', 'HH:mm')).toBe(7); + expect(handleValueDecrement(0, 'hours', 'HH:mm')).toBe(23); // wrap 24h + expect(handleValueDecrement(1, 'hours', 'hh:mm a')).toBe(12); // wrap 12h + }); + + it('should decrement minutes and seconds', () => { + expect(handleValueDecrement(30, 'minutes', 'HH:mm')).toBe(29); + expect(handleValueDecrement(0, 'minutes', 'HH:mm')).toBe(59); // wrap + expect(handleValueDecrement(0, 'seconds', 'HH:mm:ss')).toBe(59); // wrap + }); + + it('should toggle period', () => { + expect(handleValueDecrement('PM', 'period', 'hh:mm a')).toBe('AM'); + expect(handleValueDecrement('AM', 'period', 'hh:mm a')).toBe('PM'); + }); + }); +}); diff --git a/src/app-components/TimePicker/utils/keyboardNavigation.ts b/src/app-components/TimePicker/utils/keyboardNavigation.ts new file mode 100644 index 0000000000..dff032e921 --- /dev/null +++ b/src/app-components/TimePicker/utils/keyboardNavigation.ts @@ -0,0 +1,142 @@ +import type { + SegmentConstraints, + SegmentNavigationResult, + SegmentType, + TimeFormat, +} from 'src/app-components/TimePicker/types'; + +export const handleSegmentKeyDown = (event: { key: string; preventDefault: () => void }): SegmentNavigationResult => { + const { key } = event; + + switch (key) { + case 'ArrowUp': + event.preventDefault(); + return { + shouldNavigate: false, + shouldIncrement: true, + preventDefault: true, + }; + + case 'ArrowDown': + event.preventDefault(); + return { + shouldNavigate: false, + shouldDecrement: true, + preventDefault: true, + }; + + case 'ArrowRight': + event.preventDefault(); + return { + shouldNavigate: true, + direction: 'right', + preventDefault: true, + }; + + case 'ArrowLeft': + event.preventDefault(); + return { + shouldNavigate: true, + direction: 'left', + preventDefault: true, + }; + + default: + return { + shouldNavigate: false, + shouldIncrement: false, + shouldDecrement: false, + preventDefault: false, + }; + } +}; + +export const getNextSegmentIndex = ( + currentIndex: number, + direction: 'left' | 'right', + segments: SegmentType[], +): number => { + const segmentCount = segments.length; + + if (direction === 'right') { + return (currentIndex + 1) % segmentCount; + } else { + return (currentIndex - 1 + segmentCount) % segmentCount; + } +}; + +export const handleValueIncrement = ( + currentValue: number | string, + segmentType: SegmentType, + format: TimeFormat, + constraints?: SegmentConstraints, +): number | string => { + if (segmentType === 'period') { + return currentValue === 'AM' ? 'PM' : 'AM'; + } + + const numValue = typeof currentValue === 'number' ? currentValue : 0; + + // If constraints provided, use them + if (constraints) { + const currentIndex = constraints.validValues.indexOf(numValue); + if (currentIndex !== -1 && currentIndex < constraints.validValues.length - 1) { + return constraints.validValues[currentIndex + 1]; + } + return numValue; // Can't increment further + } + + // Default increment logic with wrapping + if (segmentType === 'hours') { + const is12Hour = format.includes('a'); + if (is12Hour) { + return numValue === 12 ? 1 : numValue + 1; + } else { + return numValue === 23 ? 0 : numValue + 1; + } + } + + if (segmentType === 'minutes' || segmentType === 'seconds') { + return numValue === 59 ? 0 : numValue + 1; + } + + return numValue; +}; + +export const handleValueDecrement = ( + currentValue: number | string, + segmentType: SegmentType, + format: TimeFormat, + constraints?: SegmentConstraints, +): number | string => { + if (segmentType === 'period') { + return currentValue === 'PM' ? 'AM' : 'PM'; + } + + const numValue = typeof currentValue === 'number' ? currentValue : 0; + + // If constraints provided, use them + if (constraints) { + const currentIndex = constraints.validValues.indexOf(numValue); + if (currentIndex > 0) { + return constraints.validValues[currentIndex - 1]; + } + return numValue; // Can't decrement further + } + + // Default decrement logic with wrapping + if (segmentType === 'hours') { + const is12Hour = format.includes('a'); + if (is12Hour) { + return numValue === 1 ? 12 : numValue - 1; + } else { + return numValue === 0 ? 23 : numValue - 1; + } + } + + if (segmentType === 'minutes' || segmentType === 'seconds') { + return numValue === 0 ? 59 : numValue - 1; + } + + return numValue; +}; diff --git a/src/app-components/TimePicker/utils/normalizeHour.ts b/src/app-components/TimePicker/utils/normalizeHour.ts new file mode 100644 index 0000000000..3fa8a16206 --- /dev/null +++ b/src/app-components/TimePicker/utils/normalizeHour.ts @@ -0,0 +1,20 @@ +/** + * Normalizes hour values for validation by converting 12-hour format to 24-hour format. + * For 24-hour format, returns the value unchanged. + * + * @param optionValue - The hour value from the dropdown option (1-12 for 12-hour, 0-23 for 24-hour) + * @param is12Hour - Whether the time format is 12-hour or 24-hour + * @param period - The AM/PM period (only used for 12-hour format) + * @returns The normalized hour value in 24-hour format (0-23) + */ +export function normalizeHour(optionValue: number, is12Hour: boolean, period: 'AM' | 'PM'): number { + if (!is12Hour) { + return optionValue; + } + + if (optionValue === 12) { + return period === 'AM' ? 0 : 12; + } + + return period === 'PM' ? optionValue + 12 : optionValue; +} diff --git a/src/app-components/TimePicker/utils/segmentTyping.ts b/src/app-components/TimePicker/utils/segmentTyping.ts new file mode 100644 index 0000000000..2fce490241 --- /dev/null +++ b/src/app-components/TimePicker/utils/segmentTyping.ts @@ -0,0 +1,223 @@ +import type { SegmentBuffer, SegmentType, SegmentTypingResult, TimeFormat } from 'src/app-components/TimePicker/types'; + +/** + * Process hour input with Chrome-like smart coercion + */ +export const processHourInput = (digit: string, currentBuffer: string, is12Hour: boolean): SegmentTypingResult => { + const digitNum = parseInt(digit, 10); + + if (currentBuffer === '') { + // First digit + if (is12Hour) { + // 12-hour mode: 0-1 allowed, 2-9 coerced to 0X + if (digitNum >= 0 && digitNum <= 1) { + return { value: digit, shouldAdvance: false }; + } else { + // Coerce 2-9 to 0X and advance + return { value: `0${digit}`, shouldAdvance: true }; + } + } else { + // 24-hour mode: 0-2 allowed, 3-9 coerced to 0X + if (digitNum >= 0 && digitNum <= 2) { + return { value: digit, shouldAdvance: false }; + } else { + // Coerce 3-9 to 0X and advance + return { value: `0${digit}`, shouldAdvance: true }; + } + } + } else { + // Second digit + const firstDigit = parseInt(currentBuffer, 10); + let finalValue: string; + + if (is12Hour) { + if (firstDigit === 0) { + // 01-09 valid, but 00 becomes 01 + finalValue = digitNum === 0 ? '01' : `0${digit}`; + } else if (firstDigit === 1) { + // 10-12 valid, >12 coerced to 12 + finalValue = digitNum > 2 ? '12' : `1${digit}`; + } else { + finalValue = `${currentBuffer}${digit}`; + } + } else { + // 24-hour mode + if (firstDigit === 2) { + // If first digit is 2, restrict to 20-23, coerce >23 to 23 + finalValue = digitNum > 3 ? '23' : `2${digit}`; + } else { + finalValue = `${currentBuffer}${digit}`; + } + } + + return { value: finalValue, shouldAdvance: true }; + } +}; + +/** + * Process minute/second input with coercion + */ +export const processMinuteInput = (digit: string, currentBuffer: string): SegmentTypingResult => { + const digitNum = parseInt(digit, 10); + + if (currentBuffer === '') { + // First digit: 0-5 allowed, 6-9 coerced to 0X + if (digitNum >= 0 && digitNum <= 5) { + return { value: digit, shouldAdvance: false }; + } else { + // Coerce 6-9 to 0X (complete, but don't advance - Chrome behavior) + return { value: `0${digit}`, shouldAdvance: false }; + } + } else if (currentBuffer.length === 1) { + // Second digit: always valid 0-9 + return { value: `${currentBuffer}${digit}`, shouldAdvance: false }; + } else { + // Already has 2 digits - restart with new input + if (digitNum >= 0 && digitNum <= 5) { + return { value: digit, shouldAdvance: false }; + } else { + // Coerce 6-9 to 0X + return { value: `0${digit}`, shouldAdvance: false }; + } + } +}; + +/** + * Process period (AM/PM) input + */ +export const processPeriodInput = (key: string, currentPeriod: 'AM' | 'PM'): 'AM' | 'PM' => { + const keyUpper = key.toUpperCase(); + if (keyUpper === 'A') { + return 'AM'; + } + if (keyUpper === 'P') { + return 'PM'; + } + return currentPeriod; // No change for invalid input +}; + +/** + * Check if a key should trigger navigation + */ +export const isNavigationKey = (key: string): boolean => + [':', '.', ',', ' ', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(key); + +/** + * Process segment buffer to get display and actual values + */ +export const processSegmentBuffer = (buffer: string, segmentType: SegmentType, _is12Hour: boolean): SegmentBuffer => { + if (buffer === '') { + return { + displayValue: '--', + actualValue: null, + isComplete: false, + }; + } + + if (segmentType === 'period') { + return { + displayValue: buffer, + actualValue: buffer, + isComplete: buffer === 'AM' || buffer === 'PM', + }; + } + const numValue = parseInt(buffer, 10); + if (Number.isNaN(numValue)) { + return { + displayValue: '--', + actualValue: null, + isComplete: false, + }; + } + const displayValue = buffer.length === 1 ? `0${buffer}` : buffer; + return { + displayValue, + actualValue: numValue, + isComplete: + buffer.length === 2 || + (buffer.length === 1 && + (numValue > 2 || ((segmentType === 'minutes' || segmentType === 'seconds') && numValue > 5))), + }; +}; + +/** + * Clear a segment to empty state + */ +export const clearSegment = (): { displayValue: string; actualValue: null } => ({ + displayValue: '--', + actualValue: null, +}); + +/** + * Commit segment value (fill empty minutes with 00, etc.) + */ +export const commitSegmentValue = (segmentType: SegmentType, value: number | string | null): number | string => { + if (value !== null) { + return value; + } + + if (segmentType === 'period') { + return 'AM'; // Safe default for period + } + + return 0; // Default for hours, minutes and seconds +}; +/** + * Handle character input for segment typing + */ +export const handleSegmentCharacterInput = ( + char: string, + segmentType: SegmentType, + currentBuffer: string, + format: TimeFormat, +): { + newBuffer: string; + shouldAdvance: boolean; + shouldNavigate: boolean; +} => { + const is12Hour = format.includes('a'); + + // Handle navigation characters + if (isNavigationKey(char)) { + return { + newBuffer: currentBuffer, + shouldAdvance: false, + shouldNavigate: char === ':' || char === '.' || char === ',' || char === ' ', + }; + } + + // Handle period segment + if (segmentType === 'period') { + const currentPeriod = currentBuffer === 'AM' || currentBuffer === 'PM' ? (currentBuffer as 'AM' | 'PM') : 'AM'; + const newPeriod = processPeriodInput(char, currentPeriod); + return { + newBuffer: newPeriod, + shouldAdvance: false, + shouldNavigate: false, + }; + } + + // Handle numeric segments + if (!/^\d$/.test(char)) { + // Invalid character for numeric segment + return { + newBuffer: currentBuffer, + shouldAdvance: false, + shouldNavigate: false, + }; + } + + let result: SegmentTypingResult; + + if (segmentType === 'hours') { + result = processHourInput(char, currentBuffer, is12Hour); + } else { + result = processMinuteInput(char, currentBuffer); + } + + return { + newBuffer: result.value, + shouldAdvance: result.shouldAdvance, + shouldNavigate: false, + }; +}; diff --git a/src/app-components/TimePicker/utils/timeConstraintUtils.test.ts b/src/app-components/TimePicker/utils/timeConstraintUtils.test.ts new file mode 100644 index 0000000000..5c2ac46bd5 --- /dev/null +++ b/src/app-components/TimePicker/utils/timeConstraintUtils.test.ts @@ -0,0 +1,208 @@ +import { TimeValue } from 'src/app-components/TimePicker/types'; +import { + getNextValidValue, + getSegmentConstraints, + isTimeInRange, + parseTimeString, +} from 'src/app-components/TimePicker/utils/timeConstraintUtils'; + +interface SegmentConstraints { + min: number; + max: number; + validValues: number[]; +} + +describe('Time Constraint Utilities', () => { + describe('parseTimeString', () => { + it('should parse 24-hour format correctly', () => { + const result = parseTimeString('14:30', 'HH:mm'); + expect(result).toEqual({ + hours: 14, + minutes: 30, + seconds: 0, + period: undefined, + }); + }); + + it('should parse 12-hour format correctly', () => { + const result = parseTimeString('2:30 PM', 'hh:mm a'); + expect(result).toEqual({ + hours: 14, + minutes: 30, + seconds: 0, + period: 'PM', + }); + }); + + it('should parse format with seconds', () => { + const result = parseTimeString('14:30:45', 'HH:mm:ss'); + expect(result).toEqual({ + hours: 14, + minutes: 30, + seconds: 45, + period: undefined, + }); + }); + + it('should handle empty string', () => { + const result = parseTimeString('', 'HH:mm'); + expect(result).toEqual({ + hours: 0, + minutes: 0, + seconds: 0, + period: undefined, + }); + }); + + it('should handle 12 AM correctly', () => { + const result = parseTimeString('12:00 AM', 'hh:mm a'); + expect(result).toEqual({ + hours: 0, + minutes: 0, + seconds: 0, + period: 'AM', + }); + }); + + it('should handle 12 PM correctly', () => { + const result = parseTimeString('12:00 PM', 'hh:mm a'); + expect(result).toEqual({ + hours: 12, + minutes: 0, + seconds: 0, + period: 'PM', + }); + }); + }); + + describe('isTimeInRange', () => { + const sampleTime: TimeValue = { hours: 14, minutes: 30, seconds: 0, period: 'PM' }; + + it('should return true when time is within range', () => { + const constraints = { minTime: '09:00', maxTime: '17:00' }; + const result = isTimeInRange(sampleTime, constraints, 'HH:mm'); + expect(result).toBe(true); + }); + + it('should return false when time is before minTime', () => { + const constraints = { minTime: '15:00', maxTime: '17:00' }; + const result = isTimeInRange(sampleTime, constraints, 'HH:mm'); + expect(result).toBe(false); + }); + + it('should return false when time is after maxTime', () => { + const constraints = { minTime: '09:00', maxTime: '14:00' }; + const result = isTimeInRange(sampleTime, constraints, 'HH:mm'); + expect(result).toBe(false); + }); + + it('should return true when time equals minTime', () => { + const constraints = { minTime: '14:30', maxTime: '17:00' }; + const result = isTimeInRange(sampleTime, constraints, 'HH:mm'); + expect(result).toBe(true); + }); + + it('should return true when no constraints provided', () => { + const result = isTimeInRange(sampleTime, {}, 'HH:mm'); + expect(result).toBe(true); + }); + }); + + describe('getSegmentConstraints', () => { + it('should return correct constraints for hours in 24h format', () => { + const currentTime: TimeValue = { hours: 12, minutes: 0, seconds: 0, period: 'AM' }; + const constraints = {}; + const result = getSegmentConstraints('hours', currentTime, constraints, 'HH:mm'); + + expect(result.min).toBe(0); + expect(result.max).toBe(23); + expect(result.validValues).toEqual(Array.from({ length: 24 }, (_, i) => i)); + }); + + it('should return correct constraints for hours in 12h format', () => { + const currentTime: TimeValue = { hours: 12, minutes: 0, seconds: 0, period: 'AM' }; + const constraints = {}; + const result = getSegmentConstraints('hours', currentTime, constraints, 'hh:mm a'); + + expect(result.min).toBe(1); + expect(result.max).toBe(12); + expect(result.validValues).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + }); + + it('should return constrained hours when minTime provided', () => { + const currentTime: TimeValue = { hours: 12, minutes: 0, seconds: 0, period: 'AM' }; + const constraints = { minTime: '10:00', maxTime: '16:00' }; + const result = getSegmentConstraints('hours', currentTime, constraints, 'HH:mm'); + + expect(result.validValues).toEqual([10, 11, 12, 13, 14, 15, 16]); + }); + + it('should return constrained minutes when on minTime hour', () => { + const currentTime: TimeValue = { hours: 14, minutes: 0, seconds: 0, period: 'AM' }; + const constraints = { minTime: '14:30' }; + const result = getSegmentConstraints('minutes', currentTime, constraints, 'HH:mm'); + + expect(result.validValues).toEqual(Array.from({ length: 30 }, (_, i) => i + 30)); + }); + + it('should return full minute range when hour is between constraints', () => { + const currentTime: TimeValue = { hours: 15, minutes: 0, seconds: 0, period: 'AM' }; + const constraints = { minTime: '14:30', maxTime: '16:15' }; + const result = getSegmentConstraints('minutes', currentTime, constraints, 'HH:mm'); + + expect(result.validValues).toEqual(Array.from({ length: 60 }, (_, i) => i)); + }); + }); + + describe('getNextValidValue', () => { + it('should increment value when direction is up', () => { + const constraints: SegmentConstraints = { + min: 5, + max: 15, + validValues: [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + }; + const result = getNextValidValue(8, 'up', constraints); + expect(result).toBe(9); + }); + + it('should decrement value when direction is down', () => { + const constraints: SegmentConstraints = { + min: 5, + max: 15, + validValues: [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + }; + const result = getNextValidValue(8, 'down', constraints); + expect(result).toBe(7); + }); + + it('should return null when at max and going up', () => { + const constraints: SegmentConstraints = { + min: 5, + max: 15, + validValues: [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + }; + const result = getNextValidValue(15, 'up', constraints); + expect(result).toBe(null); + }); + + it('should return null when at min and going down', () => { + const constraints: SegmentConstraints = { + min: 5, + max: 15, + validValues: [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + }; + const result = getNextValidValue(5, 'down', constraints); + expect(result).toBe(null); + }); + + it('should skip invalid values and find next valid one', () => { + const constraints: SegmentConstraints = { + min: 5, + max: 20, + validValues: [5, 8, 12, 15, 20], + }; + const result = getNextValidValue(5, 'up', constraints); + expect(result).toBe(8); + }); + }); +}); diff --git a/src/app-components/TimePicker/utils/timeConstraintUtils.ts b/src/app-components/TimePicker/utils/timeConstraintUtils.ts new file mode 100644 index 0000000000..050b5e33f1 --- /dev/null +++ b/src/app-components/TimePicker/utils/timeConstraintUtils.ts @@ -0,0 +1,207 @@ +import type { SegmentConstraints, TimeConstraints, TimeFormat, TimeValue } from 'src/app-components/TimePicker/types'; + +export const parseTimeString = (timeStr: string, format: TimeFormat): TimeValue => { + const is12Hour = format.includes('a'); + const defaultValue: TimeValue = { + hours: 0, + minutes: 0, + seconds: 0, + period: is12Hour ? 'AM' : undefined, + }; + + if (!timeStr) { + return defaultValue; + } + + const includesSeconds = format.includes('ss'); + + const parts = timeStr.replace(/\s*(AM|PM)/i, '').split(':'); + const periodMatch = timeStr.match(/(AM|PM)/i); + + const hours = parseInt(parts[0] || '0', 10); + const minutes = parseInt(parts[1] || '0', 10); + const seconds = includesSeconds ? parseInt(parts[2] || '0', 10) : 0; + const period = periodMatch ? (periodMatch[1].toUpperCase() as 'AM' | 'PM') : 'AM'; + + let actualHours = isNaN(hours) ? 0 : hours; + + // Convert 12-hour to 24-hour for internal representation + if (is12Hour) { + if (period === 'AM' && actualHours === 12) { + actualHours = 0; // 12 AM = 0 + } else if (period === 'PM' && actualHours !== 12) { + actualHours += 12; // PM hours except 12 PM + } + } + + return { + hours: actualHours, + minutes: isNaN(minutes) ? 0 : minutes, + seconds: isNaN(seconds) ? 0 : seconds, + period: is12Hour ? period : undefined, + }; +}; + +export const isTimeInRange = (time: TimeValue, constraints: TimeConstraints, format: TimeFormat): boolean => { + if (!constraints.minTime && !constraints.maxTime) { + return true; + } + + const timeInMinutes = time.hours * 60 + time.minutes; + const timeInSeconds = timeInMinutes * 60 + time.seconds; + + let minInSeconds = 0; + let maxInSeconds = 24 * 60 * 60 - 1; + + if (constraints.minTime) { + const minTime = parseTimeString(constraints.minTime, format); + minInSeconds = minTime.hours * 3600 + minTime.minutes * 60 + minTime.seconds; + } + + if (constraints.maxTime) { + const maxTime = parseTimeString(constraints.maxTime, format); + maxInSeconds = maxTime.hours * 3600 + maxTime.minutes * 60 + maxTime.seconds; + } + + return timeInSeconds >= minInSeconds && timeInSeconds <= maxInSeconds; +}; + +export const getSegmentConstraints = ( + segmentType: 'hours' | 'minutes' | 'seconds', + currentTime: TimeValue, + constraints: TimeConstraints, + format: TimeFormat, +): SegmentConstraints => { + const is12Hour = format.includes('a'); + + if (segmentType === 'hours') { + let min = is12Hour ? 1 : 0; + let max = is12Hour ? 12 : 23; + const validValues: number[] = []; + + // Parse constraints if they exist + if (constraints.minTime || constraints.maxTime) { + const minTime = constraints.minTime ? parseTimeString(constraints.minTime, format) : null; + const maxTime = constraints.maxTime ? parseTimeString(constraints.maxTime, format) : null; + + if (minTime) { + min = Math.max( + min, + is12Hour + ? minTime.hours === 0 + ? 12 + : minTime.hours > 12 + ? minTime.hours - 12 + : minTime.hours + : minTime.hours, + ); + } + if (maxTime) { + max = Math.min( + max, + is12Hour + ? maxTime.hours === 0 + ? 12 + : maxTime.hours > 12 + ? maxTime.hours - 12 + : maxTime.hours + : maxTime.hours, + ); + } + } + + for (let i = min; i <= max; i++) { + validValues.push(i); + } + + return { min, max, validValues }; + } + + if (segmentType === 'minutes') { + let min = 0; + let max = 59; + const validValues: number[] = []; + + // Check if current hour matches constraint boundaries + if (constraints.minTime) { + const minTime = parseTimeString(constraints.minTime, format); + if (currentTime.hours === minTime.hours) { + min = minTime.minutes; + } + } + + if (constraints.maxTime) { + const maxTime = parseTimeString(constraints.maxTime, format); + if (currentTime.hours === maxTime.hours) { + max = maxTime.minutes; + } + } + + for (let i = min; i <= max; i++) { + validValues.push(i); + } + + return { min, max, validValues }; + } + + if (segmentType === 'seconds') { + let min = 0; + let max = 59; + const validValues: number[] = []; + + // Check if current hour and minute match constraint boundaries + if (constraints.minTime) { + const minTime = parseTimeString(constraints.minTime, format); + if (currentTime.hours === minTime.hours && currentTime.minutes === minTime.minutes) { + min = minTime.seconds; + } + } + + if (constraints.maxTime) { + const maxTime = parseTimeString(constraints.maxTime, format); + if (currentTime.hours === maxTime.hours && currentTime.minutes === maxTime.minutes) { + max = maxTime.seconds; + } + } + + for (let i = min; i <= max; i++) { + validValues.push(i); + } + + return { min, max, validValues }; + } + + // Default fallback + return { min: 0, max: 59, validValues: Array.from({ length: 60 }, (_, i) => i) }; +}; + +export const getNextValidValue = ( + currentValue: number, + direction: 'up' | 'down', + constraints: SegmentConstraints, +): number | null => { + const { validValues } = constraints; + const currentIndex = validValues.indexOf(currentValue); + + if (currentIndex === -1) { + // Current value is not in valid values, find nearest + if (direction === 'up') { + const nextValid = validValues.find((v) => v > currentValue); + return nextValid ?? null; + } else { + const prevValid = validValues + .slice() + .reverse() + .find((v) => v < currentValue); + return prevValid ?? null; + } + } + + if (direction === 'up') { + const nextIndex = currentIndex + 1; + return nextIndex < validValues.length ? validValues[nextIndex] : null; + } else { + const prevIndex = currentIndex - 1; + return prevIndex >= 0 ? validValues[prevIndex] : null; + } +}; diff --git a/src/app-components/TimePicker/utils/timeFormatUtils.test.ts b/src/app-components/TimePicker/utils/timeFormatUtils.test.ts new file mode 100644 index 0000000000..cd83889198 --- /dev/null +++ b/src/app-components/TimePicker/utils/timeFormatUtils.test.ts @@ -0,0 +1,211 @@ +import { TimeValue } from 'src/app-components/TimePicker/types'; +import { + formatSegmentValue, + formatTimeValue, + isValidSegmentInput, + parseSegmentInput, +} from 'src/app-components/TimePicker/utils/timeFormatUtils'; + +describe('Time Format Utilities', () => { + describe('formatTimeValue', () => { + it('should format 24-hour time correctly', () => { + const time: TimeValue = { hours: 14, minutes: 30, seconds: 0, period: 'AM' }; + const result = formatTimeValue(time, 'HH:mm'); + expect(result).toBe('14:30'); + }); + + it('should format 12-hour time correctly', () => { + const time: TimeValue = { hours: 14, minutes: 30, seconds: 0, period: 'PM' }; + const result = formatTimeValue(time, 'hh:mm a'); + expect(result).toBe('02:30 PM'); + }); + + it('should format time with seconds', () => { + const time: TimeValue = { hours: 14, minutes: 30, seconds: 45, period: 'AM' }; + const result = formatTimeValue(time, 'HH:mm:ss'); + expect(result).toBe('14:30:45'); + }); + + it('should handle midnight in 12-hour format', () => { + const time: TimeValue = { hours: 0, minutes: 0, seconds: 0, period: 'AM' }; + const result = formatTimeValue(time, 'hh:mm a'); + expect(result).toBe('12:00 AM'); + }); + + it('should handle noon in 12-hour format', () => { + const time: TimeValue = { hours: 12, minutes: 0, seconds: 0, period: 'PM' }; + const result = formatTimeValue(time, 'hh:mm a'); + expect(result).toBe('12:00 PM'); + }); + + it('should pad single digits with zeros', () => { + const time: TimeValue = { hours: 9, minutes: 5, seconds: 3, period: 'AM' }; + const result = formatTimeValue(time, 'HH:mm:ss'); + expect(result).toBe('09:05:03'); + }); + }); + + describe('formatSegmentValue', () => { + it('should format hours for 24-hour display', () => { + const result = formatSegmentValue(14, 'hours', 'HH:mm'); + expect(result).toBe('14'); + }); + + it('should format hours for 12-hour display', () => { + const result = formatSegmentValue(14, 'hours', 'hh:mm a'); + expect(result).toBe('02'); + }); + + it('should format single digit hours with leading zero', () => { + const result = formatSegmentValue(5, 'hours', 'HH:mm'); + expect(result).toBe('05'); + }); + + it('should format minutes with leading zero', () => { + const result = formatSegmentValue(7, 'minutes', 'HH:mm'); + expect(result).toBe('07'); + }); + + it('should format seconds with leading zero', () => { + const result = formatSegmentValue(3, 'seconds', 'HH:mm:ss'); + expect(result).toBe('03'); + }); + + it('should handle midnight hour in 12-hour format', () => { + const result = formatSegmentValue(0, 'hours', 'hh:mm a'); + expect(result).toBe('12'); + }); + + it('should handle noon hour in 12-hour format', () => { + const result = formatSegmentValue(12, 'hours', 'hh:mm a'); + expect(result).toBe('12'); + }); + + it('should format period segment', () => { + const result = formatSegmentValue('AM', 'period', 'hh:mm a'); + expect(result).toBe('AM'); + }); + }); + + describe('parseSegmentInput', () => { + it('should parse valid hour input', () => { + const result = parseSegmentInput('14', 'hours', 'HH:mm'); + expect(result).toBe(14); + }); + + it('should parse hour input with leading zero', () => { + const result = parseSegmentInput('08', 'hours', 'HH:mm'); + expect(result).toBe(8); + }); + + it('should parse single digit input', () => { + const result = parseSegmentInput('5', 'minutes', 'HH:mm'); + expect(result).toBe(5); + }); + + it('should parse period input', () => { + const result = parseSegmentInput('PM', 'period', 'hh:mm a'); + expect(result).toBe('PM'); + }); + + it('should handle case insensitive period input', () => { + const result = parseSegmentInput('pm', 'period', 'hh:mm a'); + expect(result).toBe('PM'); + }); + + it('should return null for invalid numeric input', () => { + const result = parseSegmentInput('abc', 'hours', 'HH:mm'); + expect(result).toBe(null); + }); + + it('should return null for invalid period input', () => { + const result = parseSegmentInput('XM', 'period', 'hh:mm a'); + expect(result).toBe(null); + }); + + it('should return null for empty input', () => { + const result = parseSegmentInput('', 'hours', 'HH:mm'); + expect(result).toBe(null); + }); + }); + + describe('isValidSegmentInput', () => { + it('should validate hour input for 24-hour format', () => { + expect(isValidSegmentInput('14', 'hours', 'HH:mm')).toBe(true); + expect(isValidSegmentInput('23', 'hours', 'HH:mm')).toBe(true); + expect(isValidSegmentInput('00', 'hours', 'HH:mm')).toBe(true); + expect(isValidSegmentInput('24', 'hours', 'HH:mm')).toBe(false); + expect(isValidSegmentInput('-1', 'hours', 'HH:mm')).toBe(false); + }); + + it('should validate hour input for 12-hour format', () => { + expect(isValidSegmentInput('12', 'hours', 'hh:mm a')).toBe(true); + expect(isValidSegmentInput('01', 'hours', 'hh:mm a')).toBe(true); + expect(isValidSegmentInput('13', 'hours', 'hh:mm a')).toBe(false); + expect(isValidSegmentInput('00', 'hours', 'hh:mm a')).toBe(false); + }); + + it('should validate minute input', () => { + expect(isValidSegmentInput('00', 'minutes', 'HH:mm')).toBe(true); + expect(isValidSegmentInput('59', 'minutes', 'HH:mm')).toBe(true); + expect(isValidSegmentInput('60', 'minutes', 'HH:mm')).toBe(false); + expect(isValidSegmentInput('-1', 'minutes', 'HH:mm')).toBe(false); + }); + + it('should validate second input', () => { + expect(isValidSegmentInput('00', 'seconds', 'HH:mm:ss')).toBe(true); + expect(isValidSegmentInput('59', 'seconds', 'HH:mm:ss')).toBe(true); + expect(isValidSegmentInput('60', 'seconds', 'HH:mm:ss')).toBe(false); + }); + + it('should validate period input', () => { + expect(isValidSegmentInput('AM', 'period', 'hh:mm a')).toBe(true); + expect(isValidSegmentInput('PM', 'period', 'hh:mm a')).toBe(true); + expect(isValidSegmentInput('am', 'period', 'hh:mm a')).toBe(true); + expect(isValidSegmentInput('pm', 'period', 'hh:mm a')).toBe(true); + expect(isValidSegmentInput('XM', 'period', 'hh:mm a')).toBe(false); + }); + + it('should handle partial input during typing', () => { + expect(isValidSegmentInput('1', 'hours', 'HH:mm')).toBe(true); + expect(isValidSegmentInput('2', 'hours', 'HH:mm')).toBe(true); + expect(isValidSegmentInput('3', 'hours', 'HH:mm')).toBe(true); // Should be valid, becomes 03 + expect(isValidSegmentInput('9', 'hours', 'HH:mm')).toBe(true); // Should be valid, becomes 09 + expect(isValidSegmentInput('5', 'minutes', 'HH:mm')).toBe(true); // Should be valid, becomes 05 + }); + + it('should reject non-numeric input for numeric segments', () => { + expect(isValidSegmentInput('abc', 'hours', 'HH:mm')).toBe(false); + expect(isValidSegmentInput('1a', 'minutes', 'HH:mm')).toBe(false); + }); + }); + + describe('Edge Cases', () => { + it('should handle boundary values correctly', () => { + // Test edge cases for each segment type + expect(formatSegmentValue(0, 'hours', 'HH:mm')).toBe('00'); + expect(formatSegmentValue(23, 'hours', 'HH:mm')).toBe('23'); + expect(formatSegmentValue(0, 'minutes', 'HH:mm')).toBe('00'); + expect(formatSegmentValue(59, 'minutes', 'HH:mm')).toBe('59'); + expect(formatSegmentValue(0, 'seconds', 'HH:mm:ss')).toBe('00'); + expect(formatSegmentValue(59, 'seconds', 'HH:mm:ss')).toBe('59'); + }); + + it('should handle format variations', () => { + const time: TimeValue = { hours: 9, minutes: 5, seconds: 3, period: 'AM' }; + + expect(formatTimeValue(time, 'HH:mm')).toBe('09:05'); + expect(formatTimeValue(time, 'HH:mm:ss')).toBe('09:05:03'); + expect(formatTimeValue(time, 'hh:mm a')).toBe('09:05 AM'); + expect(formatTimeValue(time, 'hh:mm:ss a')).toBe('09:05:03 AM'); + }); + + it('should handle hour conversion edge cases', () => { + // Test 12-hour to 24-hour conversions + expect(formatSegmentValue(0, 'hours', 'hh:mm a')).toBe('12'); // Midnight + expect(formatSegmentValue(12, 'hours', 'hh:mm a')).toBe('12'); // Noon + expect(formatSegmentValue(13, 'hours', 'hh:mm a')).toBe('01'); // 1 PM + expect(formatSegmentValue(23, 'hours', 'hh:mm a')).toBe('11'); // 11 PM + }); + }); +}); diff --git a/src/app-components/TimePicker/utils/timeFormatUtils.ts b/src/app-components/TimePicker/utils/timeFormatUtils.ts new file mode 100644 index 0000000000..6d23980ebc --- /dev/null +++ b/src/app-components/TimePicker/utils/timeFormatUtils.ts @@ -0,0 +1,112 @@ +import type { SegmentType, TimeFormat, TimeValue } from 'src/app-components/TimePicker/types'; + +export const formatTimeValue = (time: TimeValue, format: TimeFormat): string => { + const is12Hour = format.includes('a'); + const includesSeconds = format.includes('ss'); + + const displayHours = is12Hour ? convertTo12HourDisplay(time.hours) : time.hours; + const hoursStr = displayHours.toString().padStart(2, '0'); + const minutesStr = time.minutes.toString().padStart(2, '0'); + const secondsStr = includesSeconds ? `:${time.seconds.toString().padStart(2, '0')}` : ''; + const period = time.hours >= 12 ? 'PM' : 'AM'; + const periodStr = is12Hour ? ` ${period}` : ''; + + return `${hoursStr}:${minutesStr}${secondsStr}${periodStr}`; +}; + +function convertTo12HourDisplay(hours: number): number { + if (hours === 0) { + return 12; + } + if (hours > 12) { + return hours - 12; + } + return hours; +} + +export const formatSegmentValue = (value: number | string, segmentType: SegmentType, format: TimeFormat): string => { + if (segmentType === 'period') { + return value.toString(); + } + + let numValue = typeof value === 'number' ? value : Number.parseInt(value, 10); + if (Number.isNaN(numValue)) { + numValue = 0; + } + if (segmentType === 'hours') { + const is12Hour = format.includes('a'); + if (is12Hour) { + const displayHour = convertTo12HourDisplay(numValue); + return displayHour.toString().padStart(2, '0'); + } + } + return numValue.toString().padStart(2, '0'); +}; + +export const parseSegmentInput = ( + input: string, + segmentType: SegmentType, + _format: TimeFormat, +): number | string | null => { + if (!input.trim()) { + return null; + } + + if (segmentType === 'period') { + const upperInput = input.toUpperCase(); + if (upperInput === 'AM' || upperInput === 'PM') { + return upperInput as 'AM' | 'PM'; + } + return null; + } + + // Parse numeric input + const numValue = parseInt(input, 10); + if (isNaN(numValue)) { + return null; + } + + return numValue; +}; + +export const isValidSegmentInput = (input: string, segmentType: SegmentType, format: TimeFormat): boolean => { + if (!input.trim()) { + return false; + } + + if (segmentType === 'period') { + const upperInput = input.toUpperCase(); + return upperInput === 'AM' || upperInput === 'PM'; + } + + // Check if it contains only digits + if (!/^\d+$/.test(input)) { + return false; + } + + const numValue = parseInt(input, 10); + if (isNaN(numValue)) { + return false; + } + + // Single digits are always valid (will be auto-padded) + if (input.length === 1) { + return true; + } + + // Validate complete values only + if (segmentType === 'hours') { + const is12Hour = format.includes('a'); + if (is12Hour) { + return numValue >= 1 && numValue <= 12; + } else { + return numValue >= 0 && numValue <= 23; + } + } + + if (segmentType === 'minutes' || segmentType === 'seconds') { + return numValue >= 0 && numValue <= 59; + } + + return false; +}; diff --git a/src/features/expressions/shared-tests/functions/displayValue/type-TimePicker.json b/src/features/expressions/shared-tests/functions/displayValue/type-TimePicker.json new file mode 100644 index 0000000000..f1ce424c34 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/displayValue/type-TimePicker.json @@ -0,0 +1,34 @@ +{ + "name": "Display value of Timepicker component", + "expression": [ + "displayValue", + "tid" + ], + "context": { + "component": "tid", + "currentLayout": "Page" + }, + "expects": "03:04", + "layouts": { + "Page": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "tid", + "type": "TimePicker", + "dataModelBindings": { + "simpleBinding": "Skjema.Tid" + }, + "format": "HH:mm" + } + ] + } + } + }, + "dataModel": { + "Skjema": { + "Tid": "03:04" + } + } +} diff --git a/src/language/texts/en.ts b/src/language/texts/en.ts index 95cfc3d421..e277af3969 100644 --- a/src/language/texts/en.ts +++ b/src/language/texts/en.ts @@ -40,6 +40,13 @@ export function en() { 'date_picker.aria_label_year_dropdown': 'Select year', 'date_picker.aria_label_month_dropdown': 'Select month', 'date_picker.format_text': 'For example {0}', + 'time_picker.invalid_time_message': 'Invalid time format. Use format {0}.', + 'time_picker.min_time_exceeded': 'The time you selected is before the earliest allowed time ({0}).', + 'time_picker.max_time_exceeded': 'The time you selected is after the latest allowed time ({0}).', + 'timepicker.hours': 'Hours', + 'timepicker.minutes': 'Minutes', + 'timepicker.seconds': 'Seconds', + 'timepicker.am_pm': 'AM/PM', 'feedback.title': '## You will soon be forwarded', 'feedback.body': 'Waiting for verification. When this is complete you will be forwarded to the next step or receipt automatically.', diff --git a/src/language/texts/nb.ts b/src/language/texts/nb.ts index 1277c0fdb1..2080da38e6 100644 --- a/src/language/texts/nb.ts +++ b/src/language/texts/nb.ts @@ -42,6 +42,13 @@ export function nb() { 'date_picker.aria_label_year_dropdown': 'Velg år', 'date_picker.aria_label_month_dropdown': 'Velg måned', 'date_picker.format_text': 'For eksempel {0}', + 'time_picker.invalid_time_message': 'Ugyldig tidsformat. Bruk formatet {0}.', + 'time_picker.min_time_exceeded': 'Tiden du har valgt er før tidligst tillatte tid ({0}).', + 'time_picker.max_time_exceeded': 'Tiden du har valgt er etter seneste tillatte tid ({0}).', + 'timepicker.hours': 'Timer', + 'timepicker.minutes': 'Minutter', + 'timepicker.seconds': 'Sekunder', + 'timepicker.am_pm': 'AM/PM', 'feedback.title': '## Du blir snart videresendt', 'feedback.body': 'Vi venter på verifikasjon, når den er på plass blir du videresendt.', 'form_filler.error_add_subform': 'Det oppstod en feil ved opprettelse av underskjema, vennligst prøv igjen', diff --git a/src/language/texts/nn.ts b/src/language/texts/nn.ts index 6f51c1025d..479a7ef8e5 100644 --- a/src/language/texts/nn.ts +++ b/src/language/texts/nn.ts @@ -42,6 +42,13 @@ export function nn() { 'date_picker.aria_label_year_dropdown': 'Vel år', 'date_picker.aria_label_month_dropdown': 'Vel månad', 'date_picker.format_text': 'Til dømes {0}', + 'time_picker.invalid_time_message': 'Ugyldig tidsformat. Bruk formatet {0}.', + 'time_picker.min_time_exceeded': 'Tida du har vald er før tidlegaste tillaten tid ({0}).', + 'time_picker.max_time_exceeded': 'Tida du har vald er etter seinaste tillaten tid ({0}).', + 'timepicker.hours': 'Timar', + 'timepicker.minutes': 'Minutt', + 'timepicker.seconds': 'Sekund', + 'timepicker.am_pm': 'AM/PM', 'feedback.title': '## Du blir snart vidaresendt', 'feedback.body': 'Vi venter på verifikasjon, når den er på plass blir du vidaresendt.', 'form_filler.error_add_subform': 'Det oppstod ein feil ved oppretting av underskjema, ver vennleg og prøv igjen.', diff --git a/src/layout/TimePicker/TimePickerComponent.test.tsx b/src/layout/TimePicker/TimePickerComponent.test.tsx new file mode 100644 index 0000000000..e2d91c08e9 --- /dev/null +++ b/src/layout/TimePicker/TimePickerComponent.test.tsx @@ -0,0 +1,123 @@ +import React from 'react'; + +import { screen } from '@testing-library/react'; + +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; +import { TimePickerComponent } from 'src/layout/TimePicker/TimePickerComponent'; +import { renderGenericComponentTest } from 'src/test/renderWithProviders'; + +describe('TimePickerComponent', () => { + it('should render time picker with label', async () => { + await renderGenericComponentTest({ + type: 'TimePicker', + renderer: (props) => , + component: { + id: 'time-picker', + type: 'TimePicker', + dataModelBindings: { + simpleBinding: { dataType: defaultDataTypeMock, field: 'time' }, + }, + textResourceBindings: { + title: 'Select time', + }, + required: false, + readOnly: false, + }, + }); + + const label = screen.getByText('Select time'); + expect(label).toBeInTheDocument(); + + // Verify that the individual time input segments are present + const inputs = screen.getAllByRole('textbox'); + expect(inputs.length).toBeGreaterThanOrEqual(2); // At least hours and minutes + }); + + it('should render time input fields with translated labels', async () => { + await renderGenericComponentTest({ + type: 'TimePicker', + renderer: (props) => , + component: { + id: 'time-picker', + type: 'TimePicker', + dataModelBindings: { + simpleBinding: { dataType: defaultDataTypeMock, field: 'time' }, + }, + format: 'HH:mm', + textResourceBindings: { + title: 'Time input', + }, + }, + }); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); // Hours and minutes + + // Check that inputs have translated aria-labels + expect(inputs[0]).toHaveAttribute('aria-label', 'Timer'); // Norwegian for 'Hours' + expect(inputs[1]).toHaveAttribute('aria-label', 'Minutter'); // Norwegian for 'Minutes' + }); + + it('should render with 12-hour format', async () => { + await renderGenericComponentTest({ + type: 'TimePicker', + renderer: (props) => , + component: { + id: 'time-picker', + type: 'TimePicker', + dataModelBindings: { + simpleBinding: { dataType: defaultDataTypeMock, field: 'time' }, + }, + format: 'hh:mm a', + }, + }); + + // Check that AM/PM segment is rendered for 12-hour format + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(3); // Hours, minutes, and AM/PM period + + // Find the AM/PM input specifically + const periodInput = inputs.find( + (input) => input.getAttribute('aria-label')?.includes('AM/PM') || input.getAttribute('placeholder') === 'AM', + ); + expect(periodInput).toBeInTheDocument(); + }); + + it('should show seconds when format includes seconds', async () => { + await renderGenericComponentTest({ + type: 'TimePicker', + renderer: (props) => , + component: { + id: 'time-picker', + type: 'TimePicker', + dataModelBindings: { + simpleBinding: { dataType: defaultDataTypeMock, field: 'time' }, + }, + format: 'HH:mm:ss', + }, + }); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(3); // Hours, minutes, and seconds + }); + + it('should be disabled when readOnly is true', async () => { + await renderGenericComponentTest({ + type: 'TimePicker', + renderer: (props) => , + component: { + id: 'time-picker', + type: 'TimePicker', + dataModelBindings: { + simpleBinding: { dataType: defaultDataTypeMock, field: 'time' }, + }, + readOnly: true, + }, + }); + + const inputs = screen.getAllByRole('textbox'); + inputs.forEach((input) => { + expect(input).toBeDisabled(); + }); + }); +}); diff --git a/src/layout/TimePicker/TimePickerComponent.tsx b/src/layout/TimePicker/TimePickerComponent.tsx new file mode 100644 index 0000000000..d19cc2e0c2 --- /dev/null +++ b/src/layout/TimePicker/TimePickerComponent.tsx @@ -0,0 +1,76 @@ +import React from 'react'; + +import { Flex } from 'src/app-components/Flex/Flex'; +import { Label } from 'src/app-components/Label/Label'; +import { TimePicker as TimePickerControl } from 'src/app-components/TimePicker/TimePicker'; +import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; +import { useLanguage } from 'src/features/language/useLanguage'; +import { ComponentStructureWrapper } from 'src/layout/ComponentStructureWrapper'; +import { useLabel } from 'src/utils/layout/useLabel'; +import { useItemWhenType } from 'src/utils/layout/useNodeItem'; +import type { PropsFromGenericComponent } from 'src/layout'; + +export function TimePickerComponent({ baseComponentId, overrideDisplay }: PropsFromGenericComponent<'TimePicker'>) { + const { + minTime, + maxTime, + format = 'HH:mm', + readOnly, + required, + id, + dataModelBindings, + grid, + } = useItemWhenType(baseComponentId, 'TimePicker'); + + const { setValue, formData } = useDataModelBindings(dataModelBindings); + const value = formData.simpleBinding || ''; + const { langAsString } = useLanguage(); + + // Create translated labels for segments + const segmentLabels = { + hours: langAsString('timepicker.hours') || 'Hours', + minutes: langAsString('timepicker.minutes') || 'Minutes', + seconds: langAsString('timepicker.seconds') || 'Seconds', + amPm: langAsString('timepicker.am_pm') || 'AM/PM', + }; + + const handleTimeChange = (timeString: string) => { + setValue('simpleBinding', timeString); + }; + + const { labelText, getRequiredComponent, getOptionalComponent, getHelpTextComponent, getDescriptionComponent } = + useLabel({ baseComponentId, overrideDisplay }); + + return ( + + ); +} diff --git a/src/layout/TimePicker/TimePickerSummary.tsx b/src/layout/TimePicker/TimePickerSummary.tsx new file mode 100644 index 0000000000..acc6a8f9b5 --- /dev/null +++ b/src/layout/TimePicker/TimePickerSummary.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { useDisplayData } from 'src/features/displayData/useDisplayData'; +import { Lang } from 'src/features/language/Lang'; +import { useUnifiedValidationsForNode } from 'src/features/validation/selectors/unifiedValidationsForNode'; +import { validationsOfSeverity } from 'src/features/validation/utils'; +import { SingleValueSummary } from 'src/layout/Summary2/CommonSummaryComponents/SingleValueSummary'; +import { SummaryContains, SummaryFlex } from 'src/layout/Summary2/SummaryComponent2/ComponentSummary'; +import { useSummaryOverrides, useSummaryProp } from 'src/layout/Summary2/summaryStoreContext'; +import { useItemWhenType } from 'src/utils/layout/useNodeItem'; +import type { Summary2Props } from 'src/layout/Summary2/SummaryComponent2/types'; + +export const TimePickerSummary = ({ targetBaseComponentId }: Summary2Props) => { + const emptyFieldText = useSummaryOverrides<'TimePicker'>(targetBaseComponentId)?.emptyFieldText; + const isCompact = useSummaryProp('isCompact'); + const displayData = useDisplayData(targetBaseComponentId); + const validations = useUnifiedValidationsForNode(targetBaseComponentId); + const errors = validationsOfSeverity(validations, 'error'); + const item = useItemWhenType(targetBaseComponentId, 'TimePicker'); + const title = item.textResourceBindings?.title; + + return ( + + } + displayData={displayData} + errors={errors} + targetBaseComponentId={targetBaseComponentId} + isCompact={isCompact} + emptyFieldText={emptyFieldText} + /> + + ); +}; diff --git a/src/layout/TimePicker/config.ts b/src/layout/TimePicker/config.ts new file mode 100644 index 0000000000..365d4e9a05 --- /dev/null +++ b/src/layout/TimePicker/config.ts @@ -0,0 +1,57 @@ +import { CG } from 'src/codegen/CG'; +import { ExprVal } from 'src/features/expressions/types'; +import { CompCategory } from 'src/layout/common'; + +export const Config = new CG.component({ + category: CompCategory.Form, + capabilities: { + renderInTable: true, + renderInButtonGroup: false, + renderInAccordion: true, + renderInAccordionGroup: false, + renderInCards: true, + renderInCardsMedia: false, + renderInTabs: true, + }, + functionality: { + customExpressions: true, + }, +}) + .addDataModelBinding(CG.common('IDataModelBindingsSimple')) + .addProperty(new CG.prop('autocomplete', new CG.const('time').optional())) + .addProperty( + new CG.prop( + 'format', + new CG.union(new CG.const('HH:mm'), new CG.const('HH:mm:ss'), new CG.const('hh:mm a'), new CG.const('hh:mm:ss a')) + .optional({ default: 'HH:mm' }) + .setTitle('Time format') + .setDescription( + 'Time format used for displaying and input. ' + + 'HH:mm for 24-hour format, hh:mm a for 12-hour format with AM/PM.', + ) + .addExample('HH:mm', 'hh:mm a', 'HH:mm:ss'), + ), + ) + .addProperty( + new CG.prop( + 'minTime', + new CG.union(new CG.expr(ExprVal.String), new CG.str()) + .optional() + .setTitle('Earliest time') + .setDescription('Sets the earliest allowed time in HH:mm format.') + .addExample('08:00', '09:30'), + ), + ) + .addProperty( + new CG.prop( + 'maxTime', + new CG.union(new CG.expr(ExprVal.String), new CG.str()) + .optional() + .setTitle('Latest time') + .setDescription('Sets the latest allowed time in HH:mm format.') + .addExample('17:00', '23:30'), + ), + ) + .extends(CG.common('LabeledComponentProps')) + .extendTextResources(CG.common('TRBLabel')) + .addSummaryOverrides(); diff --git a/src/layout/TimePicker/index.tsx b/src/layout/TimePicker/index.tsx new file mode 100644 index 0000000000..ca66cfaa4f --- /dev/null +++ b/src/layout/TimePicker/index.tsx @@ -0,0 +1,91 @@ +import React, { forwardRef } from 'react'; +import type { JSX } from 'react'; + +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; +import { useDisplayData } from 'src/features/displayData/useDisplayData'; +import { useLayoutLookups } from 'src/features/form/layout/LayoutsContext'; +import { FrontendValidationSource } from 'src/features/validation'; +import { SummaryItemSimple } from 'src/layout/Summary/SummaryItemSimple'; +import { TimePickerDef } from 'src/layout/TimePicker/config.def.generated'; +import { TimePickerComponent } from 'src/layout/TimePicker/TimePickerComponent'; +import { TimePickerSummary } from 'src/layout/TimePicker/TimePickerSummary'; +import { useTimePickerValidation } from 'src/layout/TimePicker/useTimePickerValidation'; +import { validateDataModelBindingsAny } from 'src/utils/layout/generator/validation/hooks'; +import { useNodeFormDataWhenType } from 'src/utils/layout/useNodeItem'; +import type { LayoutLookups } from 'src/features/form/layout/makeLayoutLookups'; +import type { BaseValidation, ComponentValidation } from 'src/features/validation'; +import type { + PropsFromGenericComponent, + ValidateComponent, + ValidationFilter, + ValidationFilterFunction, +} from 'src/layout'; +import type { IDataModelBindings } from 'src/layout/layout'; +import type { ExprResolver, SummaryRendererProps } from 'src/layout/LayoutComponent'; +import type { Summary2Props } from 'src/layout/Summary2/SummaryComponent2/types'; + +export class TimePicker extends TimePickerDef implements ValidateComponent, ValidationFilter { + render = forwardRef>( + function LayoutComponentTimePickerRender(props, _): JSX.Element | null { + return ; + }, + ); + + useDisplayData(baseComponentId: string): string { + const formData = useNodeFormDataWhenType(baseComponentId, 'TimePicker'); + return formData?.simpleBinding ?? ''; + } + + renderSummary(props: SummaryRendererProps): JSX.Element | null { + const displayData = useDisplayData(props.targetBaseComponentId); + return ( + + ); + } + + renderSummary2(props: Summary2Props): JSX.Element | null { + return ; + } + + useComponentValidation(baseComponentId: string): ComponentValidation[] { + return useTimePickerValidation(baseComponentId); + } + + private static schemaFormatFilter(validation: BaseValidation): boolean { + return !( + validation.source === FrontendValidationSource.Schema && validation.message.key === 'validation_errors.pattern' + ); + } + + getValidationFilters(_baseComponentId: string, _layoutLookups: LayoutLookups): ValidationFilterFunction[] { + return [TimePicker.schemaFormatFilter]; + } + + useDataModelBindingValidation(baseComponentId: string, bindings: IDataModelBindings<'TimePicker'>): string[] { + const lookupBinding = DataModels.useLookupBinding(); + const layoutLookups = useLayoutLookups(); + const _component = useLayoutLookups().getComponent(baseComponentId, 'TimePicker'); + const validation = validateDataModelBindingsAny( + baseComponentId, + bindings, + lookupBinding, + layoutLookups, + 'simpleBinding', + ['string'], + ); + const [errors] = [validation[0] ?? []]; + + return errors; + } + + evalExpressions(props: ExprResolver<'TimePicker'>) { + return { + ...this.evalDefaultExpressions(props), + minTime: props.evalStr(props.item.minTime, ''), + maxTime: props.evalStr(props.item.maxTime, ''), + }; + } +} diff --git a/src/layout/TimePicker/useTimePickerValidation.ts b/src/layout/TimePicker/useTimePickerValidation.ts new file mode 100644 index 0000000000..74f5a363a0 --- /dev/null +++ b/src/layout/TimePicker/useTimePickerValidation.ts @@ -0,0 +1,106 @@ +import { parseTimeString } from 'src/app-components/TimePicker/utils/timeConstraintUtils'; +import { FD } from 'src/features/formData/FormDataWrite'; +import { type ComponentValidation, FrontendValidationSource, ValidationMask } from 'src/features/validation'; +import { useDataModelBindingsFor } from 'src/utils/layout/hooks'; +import { useItemWhenType } from 'src/utils/layout/useNodeItem'; +import type { TimeFormat, TimeValue } from 'src/app-components/TimePicker/types'; + +const isValidTimeString = (timeStr: string, format: TimeFormat): boolean => { + if (!timeStr) { + return false; + } + + const is12Hour = format.includes('a'); + const includesSeconds = format.includes('ss'); + + const cleanTime = timeStr.trim(); + const timeRegex = is12Hour + ? includesSeconds + ? /^(\d{1,2}):(\d{2}):(\d{2})\s*(AM|PM)$/i + : /^(\d{1,2}):(\d{2})\s*(AM|PM)$/i + : includesSeconds + ? /^(\d{1,2}):(\d{2}):(\d{2})$/ + : /^(\d{1,2}):(\d{2})$/; + + const match = cleanTime.match(timeRegex); + if (!match) { + return false; + } + + const hours = parseInt(match[1], 10); + const minutes = parseInt(match[2], 10); + const seconds = includesSeconds ? parseInt(match[3], 10) : 0; + + if (is12Hour) { + if (hours < 1 || hours > 12) { + return false; + } + } else { + if (hours < 0 || hours > 23) { + return false; + } + } + + if (minutes < 0 || minutes > 59) { + return false; + } + if (seconds < 0 || seconds > 59) { + return false; + } + + return true; +}; + +const timeToSeconds = (time: TimeValue): number => time.hours * 3600 + time.minutes * 60 + time.seconds; + +export function useTimePickerValidation(baseComponentId: string): ComponentValidation[] { + const field = useDataModelBindingsFor(baseComponentId, 'TimePicker')?.simpleBinding; + const component = useItemWhenType(baseComponentId, 'TimePicker'); + const data = FD.useDebouncedPick(field); + const { minTime, maxTime, format = 'HH:mm' } = component || {}; + + const timeString = typeof data === 'string' || typeof data === 'number' ? String(data) : undefined; + if (!timeString) { + return []; + } + + const validations: ComponentValidation[] = []; + + if (!isValidTimeString(timeString, format)) { + validations.push({ + message: { key: 'time_picker.invalid_time_message', params: [format] }, + severity: 'error', + source: FrontendValidationSource.Component, + category: ValidationMask.Component, + }); + return validations; + } + + const parsedTime = parseTimeString(timeString, format); + + if (minTime && isValidTimeString(minTime, format)) { + const minParsed = parseTimeString(minTime, format); + if (timeToSeconds(parsedTime) < timeToSeconds(minParsed)) { + validations.push({ + message: { key: 'time_picker.min_time_exceeded', params: [minTime] }, + severity: 'error', + source: FrontendValidationSource.Component, + category: ValidationMask.Component, + }); + } + } + + if (maxTime && isValidTimeString(maxTime, format)) { + const maxParsed = parseTimeString(maxTime, format); + if (timeToSeconds(parsedTime) > timeToSeconds(maxParsed)) { + validations.push({ + message: { key: 'time_picker.max_time_exceeded', params: [maxTime] }, + severity: 'error', + source: FrontendValidationSource.Component, + category: ValidationMask.Component, + }); + } + } + + return validations; +}