diff --git a/src/components/forms/TimePicker/TimePicker.stories.tsx b/src/components/forms/TimePicker/TimePicker.stories.tsx index a95d46b384..4517dadbf3 100644 --- a/src/components/forms/TimePicker/TimePicker.stories.tsx +++ b/src/components/forms/TimePicker/TimePicker.stories.tsx @@ -47,6 +47,10 @@ export const WithMinAndMaxTimes: Story = { args: { hint: recommendedHintText, minTime: '9:00', maxTime: '17:00' }, } +export const With24HourFormat: Story = { + args: { hint: recommendedHintText, format: '24h' }, +} + export const WithDefaultValue: Story = { args: { hint: recommendedHintText, defaultValue: '12:00' }, } diff --git a/src/components/forms/TimePicker/TimePicker.test.tsx b/src/components/forms/TimePicker/TimePicker.test.tsx index 8679cd7d28..03c7d755c8 100644 --- a/src/components/forms/TimePicker/TimePicker.test.tsx +++ b/src/components/forms/TimePicker/TimePicker.test.tsx @@ -44,6 +44,65 @@ describe('TimePicker Component', () => { expect(comboBoxDropdownList.children.length).toEqual(7) }) + it('displays 24 hour time labels without am/pm when format is 24h', () => { + const { getByTestId } = render( + + ) + + const comboBoxDropdownList = getByTestId('combo-box-option-list') + expect(within(comboBoxDropdownList).getByText('12:00')).toBeInTheDocument() + expect(within(comboBoxDropdownList).getByText('13:00')).toBeInTheDocument() + expect(within(comboBoxDropdownList).getByText('14:00')).toBeInTheDocument() + expect( + within(comboBoxDropdownList).queryByText(/am|pm/i) + ).not.toBeInTheDocument() + }) + + it('filters on the minute as soon as a colon and digit are typed in 24h format', async () => { + const { getByTestId } = render() + + const comboBoxTextInput = getByTestId('combo-box-input') + const ninePm = getByTestId('combo-box-option-21:00') + const ninethirtyPm = getByTestId('combo-box-option-21:30') + + await userEvent.click(comboBoxTextInput) + + // A single minute digit picks the matching half hour + await userEvent.type(comboBoxTextInput, '21:0') + expect(ninePm).toHaveClass('usa-combo-box__list-option--focused') + expect(ninethirtyPm).not.toHaveClass('usa-combo-box__list-option--focused') + + await userEvent.clear(comboBoxTextInput) + await userEvent.type(comboBoxTextInput, '21:3') + expect(ninethirtyPm).toHaveClass('usa-combo-box__list-option--focused') + expect(ninePm).not.toHaveClass('usa-combo-box__list-option--focused') + }) + + it('filters midnight and tolerates a leading zero in 24h format', async () => { + const { getByTestId } = render() + + const comboBoxTextInput = getByTestId('combo-box-input') + const midnight = getByTestId('combo-box-option-00:00') + const nineAm = getByTestId('combo-box-option-09:00') + + await userEvent.click(comboBoxTextInput) + + // A single "0" focuses midnight + await userEvent.type(comboBoxTextInput, '0') + expect(midnight).toHaveClass('usa-combo-box__list-option--focused') + + // A single-digit hour still matches its zero-padded option + await userEvent.clear(comboBoxTextInput) + await userEvent.type(comboBoxTextInput, '9') + expect(nineAm).toHaveClass('usa-combo-box__list-option--focused') + }) + it('renders a label', () => { const { queryByText } = render( diff --git a/src/components/forms/TimePicker/TimePicker.tsx b/src/components/forms/TimePicker/TimePicker.tsx index 561aefb3b6..29fd61e949 100644 --- a/src/components/forms/TimePicker/TimePicker.tsx +++ b/src/components/forms/TimePicker/TimePicker.tsx @@ -10,10 +10,14 @@ import { DEFAULT_MIN_TIME, DEFAULT_MIN_TIME_MINUTES, DEFAULT_STEP, + DEFAULT_TIME_FORMAT, MIN_STEP, TIME_PICKER_CUSTOM_FILTER, + TIME_PICKER_CUSTOM_FILTER_24H, } from './constants' +export type TimePickerFormat = '12h' | '24h' + type BaseTimePickerProps = { id: string name: string @@ -24,6 +28,7 @@ type BaseTimePickerProps = { minTime?: string maxTime?: string step?: number + format?: TimePickerFormat /** Recommended text: "Select a time from the dropdown. Type into the input to filter options." */ hint?: string className?: string @@ -42,6 +47,7 @@ export const TimePicker = ({ minTime = DEFAULT_MIN_TIME, maxTime = DEFAULT_MAX_TIME, step = DEFAULT_STEP, + format = DEFAULT_TIME_FORMAT, hint, className, }: TimePickerProps): JSX.Element => { @@ -51,10 +57,13 @@ export const TimePicker = ({ const parsedMaxTime = parseTimeString(maxTime) || DEFAULT_MAX_TIME_MINUTES const validStep = step < MIN_STEP ? MIN_STEP : step const timeOptions = useMemo( - () => getTimeOptions(parsedMinTime, parsedMaxTime, validStep), - [minTime, maxTime, step] + () => getTimeOptions(parsedMinTime, parsedMaxTime, validStep, format), + [minTime, maxTime, step, format] ) + const customFilter = + format === '24h' ? TIME_PICKER_CUSTOM_FILTER_24H : TIME_PICKER_CUSTOM_FILTER + const labelId = `${name}-label` const hintId = `${name}-hint` @@ -76,7 +85,7 @@ export const TimePicker = ({ defaultValue={defaultValue} options={timeOptions} disabled={disabled} - customFilter={TIME_PICKER_CUSTOM_FILTER} + customFilter={customFilter} disableFiltering /> diff --git a/src/components/forms/TimePicker/constants.ts b/src/components/forms/TimePicker/constants.ts index 84b36e182a..d85d987512 100644 --- a/src/components/forms/TimePicker/constants.ts +++ b/src/components/forms/TimePicker/constants.ts @@ -1,5 +1,7 @@ import { CustomizableFilter } from '../ComboBox/ComboBox' +import type { TimePickerFormat } from './TimePicker' +export const DEFAULT_TIME_FORMAT: TimePickerFormat = '12h' export const DEFAULT_MAX_TIME = '23:59' export const DEFAULT_MAX_TIME_MINUTES = 24 * 60 - 1 export const DEFAULT_MIN_TIME = '00:00' @@ -16,3 +18,11 @@ export const TIME_PICKER_CUSTOM_FILTER: CustomizableFilter = { minuteQueryFilter: '[\\d]+:([0-9]{0,2})', }, } + +export const TIME_PICKER_CUSTOM_FILTER_24H: CustomizableFilter = { + filter: '0?{{ hourQueryFilter }}:{{ minuteQueryFilter }}.*', + extras: { + hourQueryFilter: '([0-9]{1,2})', + minuteQueryFilter: '[\\d]+:([0-9]{0,2})', + }, +} diff --git a/src/components/forms/TimePicker/utils.test.ts b/src/components/forms/TimePicker/utils.test.ts index fbb0cd83c2..ae35c07d55 100644 --- a/src/components/forms/TimePicker/utils.test.ts +++ b/src/components/forms/TimePicker/utils.test.ts @@ -209,6 +209,25 @@ describe('getTimeOptions', () => { ]) }) + it('returns military time labels without am/pm when format is 24h', () => { + const nineAM = parseTimeString('09:00') as number + const fivePM = parseTimeString('17:00') as number + const oneHourInMinutes = 60 + const timeOptions = getTimeOptions(nineAM, fivePM, oneHourInMinutes, '24h') + + expect(timeOptions).toEqual([ + { value: '09:00', label: '09:00' }, + { value: '10:00', label: '10:00' }, + { value: '11:00', label: '11:00' }, + { value: '12:00', label: '12:00' }, + { value: '13:00', label: '13:00' }, + { value: '14:00', label: '14:00' }, + { value: '15:00', label: '15:00' }, + { value: '16:00', label: '16:00' }, + { value: '17:00', label: '17:00' }, + ]) + }) + it('returns the expected list of times options with a custom step size', () => { const fourHoursInMinutes = 4 * 60 const timeOptions = getTimeOptions( @@ -245,6 +264,26 @@ describe('getTimeOptions', () => { ]) }) + it('returns 24 hour formatted labels when format is 24', () => { + const nineAM = parseTimeString('09:00') as number + const sixPM = parseTimeString('18:00') as number + const oneHourInMinutes = 60 + const timeOptions = getTimeOptions(nineAM, sixPM, oneHourInMinutes, '24h') + + expect(timeOptions).toEqual([ + { value: '09:00', label: '09:00' }, + { value: '10:00', label: '10:00' }, + { value: '11:00', label: '11:00' }, + { value: '12:00', label: '12:00' }, + { value: '13:00', label: '13:00' }, + { value: '14:00', label: '14:00' }, + { value: '15:00', label: '15:00' }, + { value: '16:00', label: '16:00' }, + { value: '17:00', label: '17:00' }, + { value: '18:00', label: '18:00' }, + ]) + }) + it('returns the expected list of times options with custom min and max times', () => { const nineAM = parseTimeString('09:00') as number const fivePM = parseTimeString('17:00') as number diff --git a/src/components/forms/TimePicker/utils.ts b/src/components/forms/TimePicker/utils.ts index 643b3ed09b..4bcf6487ab 100644 --- a/src/components/forms/TimePicker/utils.ts +++ b/src/components/forms/TimePicker/utils.ts @@ -1,4 +1,5 @@ import { ComboBoxOption } from '../ComboBox/ComboBox' +import type { TimePickerFormat } from './TimePicker' /** * Parse a string of hh:mm into minutes @@ -53,7 +54,8 @@ const padZeros = (value: number, length: number): string => { export const getTimeOptions = ( minTimeMinutes: number, maxTimeMinutes: number, - step: number + step: number, + format: TimePickerFormat = '12h' ): ComboBoxOption[] => { const timeOptions: ComboBoxOption[] = [] @@ -63,10 +65,13 @@ export const getTimeOptions = ( minutes += step ) { const { minute, hour24, hour12, ampm } = getTimeContext(minutes) + const value = `${padZeros(hour24, 2)}:${padZeros(minute, 2)}` + const label = + format === '24h' ? value : `${hour12}:${padZeros(minute, 2)}${ampm}` timeOptions.push({ - value: `${padZeros(hour24, 2)}:${padZeros(minute, 2)}`, - label: `${hour12}:${padZeros(minute, 2)}${ampm}`, + value, + label, }) } diff --git a/src/index.ts b/src/index.ts index e050483d14..d591b5a3a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -145,7 +145,10 @@ export type { TextInputProps } from './components/forms/TextInput/TextInput' export { TextInputMask } from './components/forms/TextInputMask/TextInputMask' export type { TextInputMaskProps } from './components/forms/TextInputMask/TextInputMask' export { TimePicker } from './components/forms/TimePicker/TimePicker' -export type { TimePickerProps } from './components/forms/TimePicker/TimePicker' +export type { + TimePickerProps, + TimePickerFormat, +} from './components/forms/TimePicker/TimePicker' export { ValidationChecklist } from './components/forms/Validation/ValidationChecklist' export type { ValidationChecklistProps } from './components/forms/Validation/ValidationChecklist' export { ValidationItem } from './components/forms/Validation/ValidationItem'