diff --git a/client/src/components/SidePanel/Schedules/ScheduleDialog.tsx b/client/src/components/SidePanel/Schedules/ScheduleDialog.tsx index ac8d01d0f4..51092129ee 100644 --- a/client/src/components/SidePanel/Schedules/ScheduleDialog.tsx +++ b/client/src/components/SidePanel/Schedules/ScheduleDialog.tsx @@ -8,9 +8,10 @@ import { Label, Radio, Button, + TimePicker, + MinutePicker, FieldMessage, Spinner, - Dropdown, OGDialog, ControlCombobox, OGDialogTemplate, @@ -32,10 +33,7 @@ import type { ScheduleFrequency, } from 'librechat-data-provider'; import type { TranslationKeys } from '~/hooks'; -import type { Meridiem } from './cadence'; import { - to12Hour, - to24Hour, describeCadence, formatRunInstant, formatScheduleDay, @@ -71,9 +69,8 @@ type ScheduleFormValues = { /** `''` means unscoped; the picker has no null option of its own. */ chatProjectId: string; frequency: ScheduleFrequency; - hour12: number; + hour: number; minute: number; - meridiem: Meridiem; daysOfWeek: number[]; /** Only read when `frequency` is `cron`; held across a switch away and back so a * user who tries a preset does not lose the expression they typed. */ @@ -89,8 +86,6 @@ const FREQUENCY_LABELS: Record = { cron: 'com_ui_schedule_cron', }; -const BASE_MINUTES = [0, 15, 30, 45]; - /** Every weekday at 09:00: a recognisable starting point to edit rather than an * empty field the user has to guess the field order from. */ const DEFAULT_CRON = '0 9 * * 1-5'; @@ -116,9 +111,8 @@ const getDefaultValues = (schedule?: TSchedule): ScheduleFormValues => { agent_id: '', chatProjectId: '', frequency: 'daily', - hour12: 9, + hour: 9, minute: 0, - meridiem: 'AM', daysOfWeek: DEFAULT_WEEKLY_DAYS, expression: DEFAULT_CRON, timezone: localTimezone, @@ -141,20 +135,17 @@ const getDefaultValues = (schedule?: TSchedule): ScheduleFormValues => { return { ...identity, frequency: 'cron', - hour12: 9, + hour: 9, minute: 0, - meridiem: 'AM', daysOfWeek: DEFAULT_WEEKLY_DAYS, expression: cadence.expression, }; } - const { hour12, meridiem } = to12Hour(cadence.hour); return { ...identity, frequency: cadence.frequency, - hour12, + hour: cadence.hour, minute: cadence.minute, - meridiem, daysOfWeek: cadence.daysOfWeek?.length ? [...cadence.daysOfWeek].sort((a, b) => a - b) : DEFAULT_WEEKLY_DAYS, @@ -164,7 +155,7 @@ const getDefaultValues = (schedule?: TSchedule): ScheduleFormValues => { type CadenceFormValues = Pick< ScheduleFormValues, - 'frequency' | 'hour12' | 'minute' | 'meridiem' | 'daysOfWeek' | 'expression' + 'frequency' | 'hour' | 'minute' | 'daysOfWeek' | 'expression' >; const buildCadence = (values: CadenceFormValues): TScheduleCadence => { @@ -174,18 +165,17 @@ const buildCadence = (values: CadenceFormValues): TScheduleCadence => { if (values.frequency === 'hourly') { return { frequency: 'hourly', hour: 0, minute: values.minute }; } - const hour = to24Hour(values.hour12, values.meridiem); if (values.frequency === 'weekly') { return { frequency: 'weekly', - hour, + hour: values.hour, minute: values.minute, daysOfWeek: values.daysOfWeek.length ? [...values.daysOfWeek].sort((a, b) => a - b) : DEFAULT_WEEKLY_DAYS, }; } - return { frequency: values.frequency, hour, minute: values.minute }; + return { frequency: values.frequency, hour: values.hour, minute: values.minute }; }; export default function ScheduleDialog({ @@ -203,6 +193,7 @@ export default function ScheduleDialog({ control, register, watch, + setValue, handleSubmit, formState: { dirtyFields, errors }, } = useForm({ @@ -216,9 +207,8 @@ export default function ScheduleDialog({ * the overwrite the fence exists to refuse. */ const openedConfigRevision = useRef(schedule?.configRevision); const frequency = watch('frequency'); - const hour12 = watch('hour12'); + const hour = watch('hour'); const minute = watch('minute'); - const meridiem = watch('meridiem'); const daysOfWeek = watch('daysOfWeek'); const expression = watch('expression'); const timezone = watch('timezone'); @@ -295,26 +285,16 @@ export default function ScheduleDialog({ [localize], ); - const hourOptions = useMemo( - () => Array.from({ length: 12 }, (_, index) => String(index + 1)), - [], - ); - - const minuteOptions = useMemo(() => { - const minutes = new Set(BASE_MINUTES); - if (schedule && !isCronCadence(schedule.cadence)) { - minutes.add(schedule.cadence.minute); - } - return [...minutes] - .sort((a, b) => a - b) - .map((minute) => ({ value: String(minute), label: String(minute).padStart(2, '0') })); - }, [schedule]); - - const meridiemOptions = useMemo( - () => [ - { value: 'AM', label: localize('com_ui_schedule_am') }, - { value: 'PM', label: localize('com_ui_schedule_pm') }, - ], + /** The picker takes its wording as props so the primitive carries no translation + * keys of its own. */ + const timeLabels = useMemo( + () => ({ + hour: localize('com_ui_schedule_hour'), + minute: localize('com_ui_schedule_minute'), + meridiem: localize('com_ui_schedule_meridiem'), + am: localize('com_ui_schedule_am'), + pm: localize('com_ui_schedule_pm'), + }), [localize], ); @@ -371,9 +351,8 @@ export default function ScheduleDialog({ * API-created hourly cadence's nonzero hour, for one). */ const cadenceTouched = dirtyFields.frequency === true || - dirtyFields.hour12 === true || + dirtyFields.hour === true || dirtyFields.minute === true || - dirtyFields.meridiem === true || dirtyFields.daysOfWeek != null || dirtyFields.expression === true; /** Whether this submit changes the schedule's TIMING. The zone alone re-times @@ -464,9 +443,9 @@ export default function ScheduleDialog({ /** Read by the summary, the preview and the interval floor, each of which walks * croner. Memoized so a name or prompt keystroke does not re-derive all three. */ const previewCadence = useMemo( - () => buildCadence({ frequency, hour12, minute, meridiem, daysOfWeek, expression }), + () => buildCadence({ frequency, hour, minute, daysOfWeek, expression }), // eslint-disable-next-line react-hooks/exhaustive-deps -- `daysKey` IS `daysOfWeek` - [frequency, hour12, minute, meridiem, daysKey, expression], + [frequency, hour, minute, daysKey, expression], ); /** Weekly with nothing selected is expressible in the form but not on the wire (the @@ -924,62 +903,38 @@ export default function ScheduleDialog({ )} -
- {frequency !== 'hourly' && ( - ( - field.onChange(Number(value))} - options={hourOptions} - variant="field" - portal={false} - ariaLabel={localize('com_ui_schedule_hour')} - testId="schedule-hour-select" - /> - )} - /> - )} + {frequency === 'hourly' ? ( ( - field.onChange(Number(value))} - options={minuteOptions} - variant="field" - portal={false} - ariaLabel={localize('com_ui_schedule_minute')} - testId="schedule-minute-select" + )} /> - {frequency !== 'hourly' && ( - ( - - )} - /> - )} -
+ ) : ( + { + setValue('hour', next.hour, { shouldDirty: true }); + setValue('minute', next.minute, { shouldDirty: true }); + }} + labels={timeLabels} + labelledBy="schedule-time-label" + locale={locale} + hour12={prefersMeridiem} + className="max-w-[16rem]" + /> + )} {timezoneField} diff --git a/client/src/components/SidePanel/Schedules/__tests__/ScheduleDialog.spec.tsx b/client/src/components/SidePanel/Schedules/__tests__/ScheduleDialog.spec.tsx index 0aebcca94d..b2408daaf8 100644 --- a/client/src/components/SidePanel/Schedules/__tests__/ScheduleDialog.spec.tsx +++ b/client/src/components/SidePanel/Schedules/__tests__/ScheduleDialog.spec.tsx @@ -233,11 +233,12 @@ describe('ScheduleDialog', () => { const user = userEvent.setup(); renderDialog(); - expect(screen.getByTestId('schedule-hour-select')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^com_ui_schedule_time/ })).toBeInTheDocument(); await user.click(screen.getByRole('radio', { name: 'com_ui_schedule_cron' })); - expect(screen.queryByTestId('schedule-hour-select')).not.toBeInTheDocument(); - expect(screen.queryByTestId('schedule-minute-select')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /^com_ui_schedule_time/ }), + ).not.toBeInTheDocument(); expect(screen.getByTestId('schedule-cron-input')).toBeInTheDocument(); }); @@ -446,6 +447,60 @@ describe('ScheduleDialog', () => { }); }); + describe('time picker', () => { + it('announces the selected time, not just the field label', () => { + renderDialog(storedSchedule({ cadence: { frequency: 'daily', hour: 8, minute: 30 } })); + + // `aria-labelledby` REPLACES a button's child text, so naming the trigger after + // the field alone left the selected time unreadable without opening the columns. + expect( + screen.getByRole('button', { name: /^com_ui_schedule_time.*8:30/ }), + ).toBeInTheDocument(); + }); + + it('sets hour and minute together, so a half-applied time cannot submit', async () => { + const user = userEvent.setup(); + renderDialog(storedSchedule({ cadence: { frequency: 'daily', hour: 8, minute: 0 } })); + + await user.click(screen.getByRole('button', { name: /^com_ui_schedule_time/ })); + const minutes = screen.getByRole('radiogroup', { name: 'com_ui_schedule_minute' }); + await user.click(within(minutes).getByRole('radio', { name: '45' })); + + await user.click(screen.getByRole('button', { name: 'com_ui_save' })); + await waitFor(() => expect(mockMutate).toHaveBeenCalled()); + expect(mockMutate.mock.calls[0][0].payload.cadence).toEqual({ + frequency: 'daily', + hour: 8, + minute: 45, + }); + }); + + it('drops to a single minutes column for an hourly cadence', async () => { + const user = userEvent.setup(); + renderDialog(); + await user.click(screen.getByRole('radio', { name: 'com_ui_schedule_hourly' })); + + await user.click(screen.getByRole('button', { name: /^com_ui_schedule_minutes_past_hour/ })); + expect( + screen.getByRole('radiogroup', { name: 'com_ui_schedule_minute' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('radiogroup', { name: 'com_ui_schedule_hour' }), + ).not.toBeInTheDocument(); + }); + + it('follows the clock format preference into the columns', async () => { + const user = userEvent.setup(); + mockUseClockFormat.mockReturnValue(false); + renderDialog(); + + await user.click(screen.getByRole('button', { name: /^com_ui_schedule_time/ })); + expect( + screen.queryByRole('radiogroup', { name: 'com_ui_schedule_meridiem' }), + ).not.toBeInTheDocument(); + }); + }); + describe('timezone', () => { it('defaults a new schedule to the browser zone and submits it', async () => { const user = userEvent.setup(); diff --git a/client/src/components/SidePanel/Schedules/cadence.ts b/client/src/components/SidePanel/Schedules/cadence.ts index 61a3f18bdc..8938164347 100644 --- a/client/src/components/SidePanel/Schedules/cadence.ts +++ b/client/src/components/SidePanel/Schedules/cadence.ts @@ -3,8 +3,6 @@ import type { TScheduleCadence } from 'librechat-data-provider'; import type { WeekStartDay } from '~/utils/clock'; import type { LocalizeFunction } from '~/common'; -export type Meridiem = 'AM' | 'PM'; - const DAY_MS = 24 * 60 * 60 * 1000; /** Mirrors the server's default weekly day (Monday) when a weekly cadence omits @@ -16,14 +14,6 @@ const SUNDAY_UTC = Date.UTC(2021, 7, 1); export const UTC_TIMEZONE = 'UTC'; -export const to12Hour = (hour: number): { hour12: number; meridiem: Meridiem } => ({ - hour12: hour % 12 === 0 ? 12 : hour % 12, - meridiem: hour >= 12 ? 'PM' : 'AM', -}); - -export const to24Hour = (hour12: number, meridiem: Meridiem): number => - meridiem === 'PM' ? (hour12 % 12) + 12 : hour12 % 12; - export const formatScheduleTime = ( hour: number, minute: number, diff --git a/package-lock.json b/package-lock.json index 188de2253d..3b74942ab3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43498,6 +43498,7 @@ "@radix-ui/react-hover-card": "^1.0.5", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-select": "^2.2.5", diff --git a/packages/client/package.json b/packages/client/package.json index c5476f32ae..ebf76b65ee 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -54,6 +54,7 @@ "@radix-ui/react-hover-card": "^1.0.5", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-select": "^2.2.5", diff --git a/packages/client/src/components/TimePicker.spec.tsx b/packages/client/src/components/TimePicker.spec.tsx new file mode 100644 index 0000000000..2c03d779cf --- /dev/null +++ b/packages/client/src/components/TimePicker.spec.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, within } from '@testing-library/react'; +import TimePicker, { MinutePicker } from './TimePicker'; + +const LABELS = { hour: 'Hour', minute: 'Minute', meridiem: 'AM or PM', am: 'AM', pm: 'PM' }; + +function Harness({ locale = 'en-US', hour12 = true }: { locale?: string; hour12?: boolean }) { + const [time, setTime] = useState({ hour: 9, minute: 0 }); + return ( + <> + + {'Time'} + {`${time.hour}:${time.minute}`} + + ); +} + +describe('TimePicker', () => { + it('shows the time in the locale convention and opens three columns', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: 'Time 9:00 AM' }); + + await user.click(trigger); + expect(screen.getByRole('radiogroup', { name: 'Hour' })).toBeInTheDocument(); + expect(screen.getByRole('radiogroup', { name: 'Minute' })).toBeInTheDocument(); + expect(screen.getByRole('radiogroup', { name: 'AM or PM' })).toBeInTheDocument(); + }); + + it('announces the selected time, not just the field label', () => { + // `aria-labelledby` REPLACES a button's child text, so pointing it at the field + // label alone announced "Time" and left the selected value unreadable without + // opening the columns. + render(); + + expect(screen.getByRole('button', { name: 'Time 9:00 AM' })).toBeInTheDocument(); + }); + + it('keeps the hour when switching to PM', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^Time/ })); + const meridiem = screen.getByRole('radiogroup', { name: 'AM or PM' }); + await user.click(within(meridiem).getByRole('radio', { name: 'PM' })); + + expect(screen.getByTestId('value')).toHaveTextContent('21:0'); + }); + + it('moves through the minute column with the arrow keys and wraps', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^Time/ })); + const minutes = screen.getByRole('radiogroup', { name: 'Minute' }); + within(minutes).getByRole('radio', { name: '00' }).focus(); + + await user.keyboard('{ArrowDown}'); + expect(screen.getByTestId('value')).toHaveTextContent('9:1'); + + // wrapping backwards past 00 reaches the end of the column rather than sticking + within(minutes).getByRole('radio', { name: '00' }).focus(); + await user.keyboard('{ArrowUp}'); + expect(screen.getByTestId('value')).toHaveTextContent('9:59'); + }); +}); + +describe('TimePicker initial centering', () => { + /** jsdom reports every layout metric as 0, so the scroll maths cannot run against + * it. Give the column and its rows real ones. */ + function stubLayout() { + const asNumber = (el: Element, attr: string) => Number(el.getAttribute(attr) ?? 0); + jest.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function ( + this: HTMLElement, + ) { + return this.getAttribute('role') === 'radiogroup' ? 200 : 20; + }); + jest.spyOn(HTMLElement.prototype, 'offsetTop', 'get').mockImplementation(function ( + this: HTMLElement, + ) { + return asNumber(this, 'data-value') * 20; + }); + } + + afterEach(() => jest.restoreAllMocks()); + + it('scrolls the selected row into the middle of its column on open', async () => { + // React attaches descendant refs BEFORE the parent's, so doing this from the + // button's ref callback read a null column and silently skipped the scroll, + // leaving a picker opened on 9:00 sitting at 00. + stubLayout(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^Time/ })); + + const hours = screen.getByRole('radiogroup', { name: 'Hour' }); + // row 9 at 20px each, centred in a 200px column: 180 - (200 - 20) / 2 + expect(hours.scrollTop).toBe(90); + }); +}); + +describe('TimePicker clock format', () => { + it('renders 24-hour columns when the host app resolves a 24-hour clock', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByRole('button', { name: /^Time/ })).toHaveTextContent('9:00'); + await user.click(screen.getByRole('button', { name: /^Time/ })); + expect(screen.queryByRole('radiogroup', { name: 'AM or PM' })).not.toBeInTheDocument(); + }); + + it('renders a meridiem column for a 24-hour LOCALE when the app says 12-hour', async () => { + // The prop is the whole answer, deliberately: the app has already resolved its + // Clock format setting, and re-guessing from the locale here would let the picker + // disagree with the summary printed beside it. + const user = userEvent.setup(); + render(); + + expect(screen.getByRole('button', { name: /^Time/ })).toHaveTextContent(/AM/i); + await user.click(screen.getByRole('button', { name: /^Time/ })); + expect(screen.getByRole('radiogroup', { name: 'AM or PM' })).toBeInTheDocument(); + }); +}); + +describe('MinutePicker', () => { + function MinuteHarness() { + const [minute, setMinute] = useState(0); + return ( + <> + + {'Minutes past the hour'} + {String(minute)} + + ); + } + + it('opens a single minutes column and selects from it', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: 'Minutes past the hour 00' }); + expect(trigger).toHaveTextContent('00'); + + await user.click(trigger); + const minutes = screen.getByRole('radiogroup', { name: 'Minute' }); + expect(screen.queryByRole('radiogroup', { name: 'Hour' })).not.toBeInTheDocument(); + + await user.click(within(minutes).getByRole('radio', { name: '30' })); + expect(screen.getByTestId('minute')).toHaveTextContent('30'); + }); +}); diff --git a/packages/client/src/components/TimePicker.tsx b/packages/client/src/components/TimePicker.tsx new file mode 100644 index 0000000000..d3301f2e3d --- /dev/null +++ b/packages/client/src/components/TimePicker.tsx @@ -0,0 +1,346 @@ +import { useId, useLayoutEffect, useRef, useState } from 'react'; +import { Clock } from 'lucide-react'; +import { Root, Trigger, Content } from '@radix-ui/react-popover'; +import type { KeyboardEvent, ReactNode } from 'react'; +import { fieldControl } from './Field'; +import { cn } from '~/utils'; + +const HOURS_24 = Array.from({ length: 24 }, (_, value) => value); +const HOURS_12 = Array.from({ length: 12 }, (_, index) => (index === 0 ? 12 : index)); +const MINUTES = Array.from({ length: 60 }, (_, value) => value); + +const pad = (value: number): string => String(value).padStart(2, '0'); + +const formatTime = ( + hour: number, + minute: number, + locale: string | undefined, + hour12: boolean, +): string => + new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', hour12 }).format( + new Date(2000, 0, 1, hour, minute), + ); + +export interface TimeColumnProps { + label: string; + values: number[]; + selected: number; + format: (value: number) => string; + onSelect: (value: number) => void; +} + +/** + * One scrolling column of the picker. Radio semantics rather than a listbox of + * buttons: the options are mutually exclusive values, and a roving tabindex keeps + * the column a single tab stop that arrow keys move within, which is what a + * keyboard user expects from a set of 60 minutes. + */ +export function TimeColumn({ + label, + values, + selected, + format, + onSelect, +}: TimeColumnProps): JSX.Element { + const listRef = useRef(null); + /** The value to centre on, frozen at mount. Centering runs once per open, not on + * every re-render: re-centering as the user clicks down a column would yank the + * row they just aimed at back to the middle. */ + const initialSelected = useRef(selected); + + /** + * Opening on 9:00 must not strand the user at 00:00 in a 60-row list. In a layout + * effect rather than a ref callback because React attaches descendant refs BEFORE + * the parent's: from the button's callback `listRef` is still null on the mount + * that matters, so the scroll never happened. Scrolled by hand rather than with + * `scrollIntoView`, which also scrolls every scrollable ancestor and would shove + * the surrounding dialog around the page. + */ + useLayoutEffect(() => { + const list = listRef.current; + const node = list?.querySelector( + `[data-value="${initialSelected.current}"]`, + ); + if (list == null || node == null) { + return; + } + list.scrollTop = node.offsetTop - (list.clientHeight - node.clientHeight) / 2; + }, []); + + const focusValue = (value: number) => { + const node = listRef.current?.querySelector(`[data-value="${value}"]`); + node?.focus(); + // Optional call: scrolling is an enhancement, and not every environment + // rendering this (jsdom, older embedded webviews) implements it. + node?.scrollIntoView?.({ block: 'nearest' }); + }; + + const handleKeyDown = (event: KeyboardEvent, index: number) => { + const moves: Record = { + ArrowDown: index + 1, + ArrowRight: index + 1, + ArrowUp: index - 1, + ArrowLeft: index - 1, + Home: 0, + End: values.length - 1, + }; + const target = moves[event.key]; + if (target == null) { + return; + } + event.preventDefault(); + // Wraps, so holding ArrowUp from midnight reaches 23:00 without a dead end. + const next = values[(target + values.length) % values.length]; + onSelect(next); + focusValue(next); + }; + + return ( +
+ {label} +
+ {values.map((value, index) => { + const isSelected = value === selected; + return ( + + ); + })} +
+
+ ); +} + +interface PickerShellProps { + id?: string; + labelledBy?: string; + className?: string; + display: string; + contentClassName: string; + children: ReactNode; +} + +/** + * The trigger and popover surface both pickers share: a `fieldControl` button that + * reads as an Input beside one, and the same enter/exit motion as the other Radix + * primitives. Held in one place so a theme or interaction fix lands on both. + */ +function PickerShell({ + id, + labelledBy, + className, + display, + contentClassName, + children, +}: PickerShellProps): JSX.Element { + const [open, setOpen] = useState(false); + const valueId = `${useId()}value`; + + return ( + + + + + {/* Deliberately NOT portaled. A Radix dialog sets `pointer-events: none` on + the body while open, so a popover portaled out of it renders correctly but + receives no clicks or wheel events, and its focus trap puts the content out + of tab order too. Rendering in place keeps the columns usable, so a dialog + hosting one needs `overflow-visible` to avoid clipping it. */} + + {children} + + + ); +} + +export interface TimePickerLabels { + hour: string; + minute: string; + meridiem: string; + am: string; + pm: string; +} + +export interface TimePickerProps { + hour: number; + minute: number; + onChange: (next: { hour: number; minute: number }) => void; + /** Column headings and the meridiem option names. Passed in rather than looked + * up here so this primitive carries no translation keys of its own. */ + labels: TimePickerLabels; + id?: string; + labelledBy?: string; + className?: string; + locale?: string; + /** Required rather than guessed from `locale`. The host app resolves its "Clock + * format" setting once and passes the answer down; deriving a second answer here + * would let the picker and the summary beside it disagree about the same time. */ + hour12: boolean; +} + +/** + * Hour, minute and (where the locale uses one) meridiem columns behind a single + * trigger. Replaces ``, whose rendering the browser owns and + * which cannot be brought in line with the rest of the form. + */ +export default function TimePicker({ + hour, + minute, + onChange, + labels, + id, + labelledBy, + className, + locale, + hour12, +}: TimePickerProps): JSX.Element { + const isPm = hour >= 12; + + const displayHour = hour12 ? HOURS_12[hour % 12] : hour; + const setHour = (value: number) => { + if (!hour12) { + onChange({ hour: value, minute }); + return; + } + const base = value % 12; + onChange({ hour: isPm ? base + 12 : base, minute }); + }; + + return ( + +
+ + onChange({ hour, minute: value })} + /> + {hour12 && ( + (value === 1 ? labels.pm : labels.am)} + onSelect={(value) => { + const base = hour % 12; + onChange({ hour: value === 1 ? base + 12 : base, minute }); + }} + /> + )} +
+
+ ); +} + +export interface MinutePickerProps { + minute: number; + onChange: (minute: number) => void; + label: string; + id?: string; + labelledBy?: string; + className?: string; +} + +/** + * A single minutes column behind the same trigger, so an hour-less cadence reads + * as the same picker with its other columns dropped rather than a different widget. + */ +export function MinutePicker({ + minute, + onChange, + label, + id, + labelledBy, + className, +}: MinutePickerProps): JSX.Element { + return ( + + + + ); +} diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts index b03669d4ad..a511164141 100644 --- a/packages/client/src/components/index.ts +++ b/packages/client/src/components/index.ts @@ -58,6 +58,13 @@ export { default as CheckboxButton } from './CheckboxButton'; export { default as DialogTemplate } from './DialogTemplate'; export { default as SelectDropDown } from './SelectDropDown'; export { default as ControlCombobox } from './ControlCombobox'; +export { default as TimePicker, MinutePicker, TimeColumn } from './TimePicker'; +export type { + TimePickerProps, + TimePickerLabels, + MinutePickerProps, + TimeColumnProps, +} from './TimePicker'; export { default as OGDialogTemplate } from './OGDialogTemplate'; export { default as InputWithDropdown } from './InputWithDropDown'; export { default as AnimatedSearchInput } from './AnimatedSearchInput';