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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/components/forms/TimePicker/TimePicker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
}
59 changes: 59 additions & 0 deletions src/components/forms/TimePicker/TimePicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TimePicker
{...testProps}
format="24h"
minTime="12:00"
maxTime="14:00"
step={60}
/>
)

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(<TimePicker {...testProps} format="24h" />)

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(<TimePicker {...testProps} format="24h" />)

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(
<TimePicker {...testProps} label="test label" />
Expand Down
15 changes: 12 additions & 3 deletions src/components/forms/TimePicker/TimePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 => {
Expand All @@ -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`

Expand All @@ -76,7 +85,7 @@ export const TimePicker = ({
defaultValue={defaultValue}
options={timeOptions}
disabled={disabled}
customFilter={TIME_PICKER_CUSTOM_FILTER}
customFilter={customFilter}
disableFiltering
/>
</FormGroup>
Expand Down
10 changes: 10 additions & 0 deletions src/components/forms/TimePicker/constants.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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})',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We could have more specific regex here, but made it as similar to the other TIME_PICKER_CUSTOM_FILTER so that they behave as similar as possible.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think that's the right move. I haven't double checked, but I'm fairly certain a lot of these supporting constants and logic are lifted almost 1:1 from USWDS, so keeping it "similar" is exactly the right call.

minuteQueryFilter: '[\\d]+:([0-9]{0,2})',
},
}
39 changes: 39 additions & 0 deletions src/components/forms/TimePicker/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions src/components/forms/TimePicker/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ComboBoxOption } from '../ComboBox/ComboBox'
import type { TimePickerFormat } from './TimePicker'

/**
* Parse a string of hh:mm into minutes
Expand Down Expand Up @@ -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[] = []

Expand All @@ -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,
})
}

Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading