⏱️ feat: Shared Time Picker for Schedule Times (#15122)

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 `<input type="time">`: 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.
This commit is contained in:
Marco Beretta 2026-08-24 00:47:33 +02:00 committed by GitHub
parent 7834ebab33
commit 8a118c7cb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 629 additions and 107 deletions

View file

@ -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<ScheduleFrequency, TranslationKeys> = {
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<ScheduleFormValues>({
@ -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({
)}
</Label>
</legend>
<div
className={cn(
'grid gap-2',
frequency === 'hourly' ? 'max-w-[8rem] grid-cols-1' : 'grid-cols-3',
)}
>
{frequency !== 'hourly' && (
<Controller
name="hour12"
control={control}
render={({ field }) => (
<Dropdown
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={hourOptions}
variant="field"
portal={false}
ariaLabel={localize('com_ui_schedule_hour')}
testId="schedule-hour-select"
/>
)}
/>
)}
{frequency === 'hourly' ? (
<Controller
name="minute"
control={control}
render={({ field }) => (
<Dropdown
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={minuteOptions}
variant="field"
portal={false}
ariaLabel={localize('com_ui_schedule_minute')}
testId="schedule-minute-select"
<MinutePicker
minute={field.value}
onChange={field.onChange}
label={localize('com_ui_schedule_minute')}
labelledBy="schedule-time-label"
className="max-w-[8rem]"
/>
)}
/>
{frequency !== 'hourly' && (
<Controller
name="meridiem"
control={control}
render={({ field }) => (
<Dropdown
value={field.value}
onChange={field.onChange}
options={meridiemOptions}
variant="field"
portal={false}
ariaLabel={localize('com_ui_schedule_meridiem')}
testId="schedule-meridiem-select"
/>
)}
/>
)}
</div>
) : (
<TimePicker
hour={hour}
minute={minute}
/* One control, so one change: setting hour and minute through
separate fields let a half-applied edit submit a time the user
never picked. */
onChange={(next) => {
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]"
/>
)}
</fieldset>
{timezoneField}
</div>

View file

@ -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();

View file

@ -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,

1
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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 (
<>
<TimePicker
hour={time.hour}
minute={time.minute}
locale={locale}
hour12={hour12}
labels={LABELS}
onChange={setTime}
labelledBy="time-label"
/>
<span id="time-label">{'Time'}</span>
<output data-testid="value">{`${time.hour}:${time.minute}`}</output>
</>
);
}
describe('TimePicker', () => {
it('shows the time in the locale convention and opens three columns', async () => {
const user = userEvent.setup();
render(<Harness />);
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(<Harness />);
expect(screen.getByRole('button', { name: 'Time 9:00 AM' })).toBeInTheDocument();
});
it('keeps the hour when switching to PM', async () => {
const user = userEvent.setup();
render(<Harness />);
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(<Harness />);
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(<Harness hour12={false} />);
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(<Harness locale="en-US" hour12={false} />);
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(<Harness locale="en-GB" hour12 />);
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 (
<>
<MinutePicker
minute={minute}
onChange={setMinute}
label={LABELS.minute}
labelledBy="minute-label"
/>
<span id="minute-label">{'Minutes past the hour'}</span>
<output data-testid="minute">{String(minute)}</output>
</>
);
}
it('opens a single minutes column and selects from it', async () => {
const user = userEvent.setup();
render(<MinuteHarness />);
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');
});
});

View file

@ -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<HTMLDivElement>(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<HTMLButtonElement>(
`[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<HTMLButtonElement>(`[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<string, number> = {
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 (
<div className="flex min-w-0 flex-1 flex-col">
<span className="px-1 pb-1 text-xs font-medium text-text-secondary">{label}</span>
<div
ref={listRef}
role="radiogroup"
aria-label={label}
// `relative` so the selected row's `offsetTop` is measured against this
// column and not whatever positioned ancestor the popover happens to have.
className="relative max-h-52 overflow-y-auto rounded-lg border border-border-light p-1"
>
{values.map((value, index) => {
const isSelected = value === selected;
return (
<button
key={value}
type="button"
role="radio"
data-value={value}
aria-checked={isSelected}
tabIndex={isSelected ? 0 : -1}
onClick={() => onSelect(value)}
onKeyDown={(event) => handleKeyDown(event, index)}
className={cn(
'w-full rounded-md px-2 py-1 text-center text-sm tabular-nums transition-colors',
isSelected
? 'bg-surface-active font-medium text-text-primary'
: 'text-text-secondary hover:bg-surface-hover',
)}
>
{format(value)}
</button>
);
})}
</div>
</div>
);
}
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 (
<Root open={open} onOpenChange={setOpen}>
<Trigger asChild>
<button
id={id}
type="button"
// The value is part of the NAME, not just visible text: `aria-labelledby`
// replaces a button's child text, so pointing it at the field label alone
// announced "Time" and left a screen reader user unable to tell what time
// was selected without opening the columns and reading them.
aria-labelledby={labelledBy == null ? valueId : `${labelledBy} ${valueId}`}
className={cn(
fieldControl,
'items-center justify-between gap-2 text-text-primary',
'hover:bg-surface-hover radix-state-open:bg-surface-hover',
className,
)}
>
<span id={valueId} className="tabular-nums">
{display}
</span>
<Clock className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
</button>
</Trigger>
{/* 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. */}
<Content
side="bottom"
align="start"
sideOffset={6}
className={cn(
'z-[999] rounded-xl border border-border-light bg-surface-secondary p-2 shadow-lg outline-none',
// Same enter/exit motion as the shared Radix primitives (Combobox,
// Select, DropdownMenu): fade + zoom from the trigger edge, with Radix's
// own transform origin so the zoom grows out of wherever it was placed.
'origin-[--radix-popover-content-transform-origin]',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
'motion-reduce:animate-none',
contentClassName,
)}
>
{children}
</Content>
</Root>
);
}
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 `<input type="time">`, 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 (
<PickerShell
id={id}
labelledBy={labelledBy}
className={className}
display={formatTime(hour, minute, locale, hour12)}
contentClassName="w-64"
>
<div className="flex gap-2">
<TimeColumn
label={labels.hour}
values={hour12 ? HOURS_12 : HOURS_24}
selected={displayHour}
format={hour12 ? String : pad}
onSelect={setHour}
/>
<TimeColumn
label={labels.minute}
values={MINUTES}
selected={minute}
format={pad}
onSelect={(value) => onChange({ hour, minute: value })}
/>
{hour12 && (
<TimeColumn
label={labels.meridiem}
values={[0, 1]}
selected={isPm ? 1 : 0}
format={(value) => (value === 1 ? labels.pm : labels.am)}
onSelect={(value) => {
const base = hour % 12;
onChange({ hour: value === 1 ? base + 12 : base, minute });
}}
/>
)}
</div>
</PickerShell>
);
}
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 (
<PickerShell
id={id}
labelledBy={labelledBy}
className={className}
display={pad(minute)}
contentClassName="w-36"
>
<TimeColumn
label={label}
values={MINUTES}
selected={minute}
format={pad}
onSelect={onChange}
/>
</PickerShell>
);
}

View file

@ -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';