From 8a118c7cb3d0ff5624504efc0304e6dda7f77777 Mon Sep 17 00:00:00 2001 From: Marco Beretta Date: Mon, 24 Aug 2026 00:47:33 +0200 Subject: [PATCH] =?UTF-8?q?=E2=8F=B1=EF=B8=8F=20feat:=20Shared=20Time=20Pi?= =?UTF-8?q?cker=20for=20Schedule=20Times=20(#15122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A schedule's time was three dropdowns side by side: hour, minute, meridiem. That is three controls for one value, it cannot be read at a glance, and the minute list was a fixed set of four with the stored value bolted on, so a schedule already running at :07 could be kept but never chosen. They become one TimePicker: hour, minute and, where the clock format calls for one, meridiem, as scrollable columns behind a single trigger showing the selected time. An hourly cadence gets MinutePicker, the same control with its other columns dropped, so it reads as the same widget rather than a different one. Both live in packages/client with their wording passed in as props, so the primitive carries no translation keys of its own. Not ``: the browser owns its rendering, and it cannot be brought in line with the rest of the form. `hour12` is a required prop rather than a locale-derived guess. The app has already resolved its Clock format setting, and re-deriving the answer inside the picker would let it disagree with the summary printed beside it. The trigger names its selected value as well as its field: `aria-labelledby` replaces a button's child text, so pointing it at the label alone announced "Time" and left a screen reader user unable to tell what was selected without opening the columns and reading them. The columns are a roving-tabindex radiogroup, arrow keys wrap, and the selected row is scrolled to the middle of its column on open. The popover is 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. Hour and minute are set in one change. Behind separate fields a half-applied edit could submit a time the user never picked, and the form now carries the hour as the 0-23 value the cadence stores rather than a 12-hour value plus a meridiem it has to recombine. --- .../SidePanel/Schedules/ScheduleDialog.tsx | 143 +++----- .../__tests__/ScheduleDialog.spec.tsx | 61 ++- .../components/SidePanel/Schedules/cadence.ts | 10 - package-lock.json | 1 + packages/client/package.json | 1 + .../client/src/components/TimePicker.spec.tsx | 167 +++++++++ packages/client/src/components/TimePicker.tsx | 346 ++++++++++++++++++ packages/client/src/components/index.ts | 7 + 8 files changed, 629 insertions(+), 107 deletions(-) create mode 100644 packages/client/src/components/TimePicker.spec.tsx create mode 100644 packages/client/src/components/TimePicker.tsx 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';