{
// Tap outside the wheel card (on the covered calendar area)
// collapses the wheels without selecting a date, matching the
// native behavior.
- if (e.target === e.currentTarget) setTimeOpen(false);
+ if (e.target === e.currentTarget) closeTime();
}}
>
@@ -622,9 +680,10 @@ export const DateTimeInputBase = forwardRef<
setTimeOpen(o => !o)}
+ onClick={() => (timeOpen ? closeTime() : setTimeOpen(true))}
aria-expanded={timeOpen}
disabled={disabled}
>
diff --git a/bulma-ui/src/form/TimeInput.stories.tsx b/bulma-ui/src/form/TimeInput.stories.tsx
index 2a2bdec43..38d69ca1a 100644
--- a/bulma-ui/src/form/TimeInput.stories.tsx
+++ b/bulma-ui/src/form/TimeInput.stories.tsx
@@ -345,7 +345,7 @@ export const ManualEntry24h: Story = {
docs: {
description: {
story:
- '24-hour segmented entry with `openOnFocus={false}` — the hours segment accepts `00`–`23` directly, and the wheel popover only opens from the launcher icon or `↓`.',
+ '24-hour segmented entry with `openOnFocus={false}` — the hours segment accepts `00`–`23` directly, and the wheel popover only opens from the launcher icon or `Alt+↓`.',
},
},
},
@@ -362,7 +362,7 @@ export const ManualEntryIncrementSteps: Story = {
docs: {
description: {
story:
- 'Increments steer the wheels, not the keyboard — typed minutes stay free-grained, so `09:32` goes in even with a step of 5. With `openOnFocus={false}` the stepped wheels only appear from the launcher icon or `↓`.',
+ 'Increments steer the wheels, not the keyboard — typed minutes stay free-grained, so `09:32` goes in even with a step of 5. With `openOnFocus={false}` the stepped wheels only appear from the launcher icon or `Alt+↓`.',
},
},
},
@@ -409,7 +409,7 @@ export const ManualEntryFormats: Story = {
docs: {
description: {
story:
- 'The same token formats, typing-first — `openOnFocus={false}` on each field keeps the wheels behind the launcher icon or `↓` while you type through the segments.',
+ 'The same token formats, typing-first — `openOnFocus={false}` on each field keeps the wheels behind the launcher icon or `Alt+↓` while you type through the segments.',
},
},
},
@@ -425,7 +425,7 @@ export const ManualEntryLauncherIcon: Story = {
docs: {
description: {
story:
- 'With `openOnFocus={false}` the custom `triggerIconName` launcher (or `↓`) is the only way to open the spinner — clicking into the field just starts typing.',
+ 'With `openOnFocus={false}` the custom `triggerIconName` launcher (or `Alt+↓`) is the only way to open the spinner — clicking into the field just starts typing.',
},
},
},
@@ -459,7 +459,7 @@ export const ManualEntrySizes: Story = {
docs: {
description: {
story:
- 'Every size with `openOnFocus={false}` — segmented typing works the same at each scale, and the spinner waits for the launcher icon or `↓`.',
+ 'Every size with `openOnFocus={false}` — segmented typing works the same at each scale, and the spinner waits for the launcher icon or `Alt+↓`.',
},
},
},
@@ -479,7 +479,7 @@ export const ManualEntryColors: Story = {
docs: {
description: {
story:
- 'Every color with `openOnFocus={false}` — type into each field directly; the wheels only open from the launcher icon or `↓`.',
+ 'Every color with `openOnFocus={false}` — type into each field directly; the wheels only open from the launcher icon or `Alt+↓`.',
},
},
},
@@ -497,7 +497,7 @@ export const ManualEntryAudioTick: Story = {
docs: {
description: {
story:
- 'The same audio-thunk feedback with `openOnFocus={false}` — typing into the field is silent; the tick fires when you open the wheels via the launcher icon (or `↓`) and scroll them.',
+ 'The same audio-thunk feedback with `openOnFocus={false}` — typing into the field is silent; the tick fires when you open the wheels via the launcher icon (or `Alt+↓`) and scroll them.',
},
},
},
@@ -515,7 +515,7 @@ export const ManualEntryHaptics: Story = {
docs: {
description: {
story:
- 'Auto-routed haptics with `openOnFocus={false}` — the per-platform feedback fires when the wheels opened via the launcher icon (or `↓`) are scrolled, while typed entry stays feedback-free.',
+ 'Auto-routed haptics with `openOnFocus={false}` — the per-platform feedback fires when the wheels opened via the launcher icon (or `Alt+↓`) are scrolled, while typed entry stays feedback-free.',
},
},
},
diff --git a/bulma-ui/src/form/TimeInputBase.tsx b/bulma-ui/src/form/TimeInputBase.tsx
index ba04c97e1..1fb0caaf7 100644
--- a/bulma-ui/src/form/TimeInputBase.tsx
+++ b/bulma-ui/src/form/TimeInputBase.tsx
@@ -130,7 +130,7 @@ export interface TimeInputBaseProps
* Open the popover when the input is focused. Focus that a closing popover
* hands back to the input leaves it closed. Dismissing it commits nothing:
* an empty field stays empty, and leaving afterwards commits only what was
- * typed since.
+ * typed since. With it off, the launcher or Alt+ArrowDown opens it.
*/
openOnFocus?: boolean;
/** Close the popover after a time is selected (off by default). */
@@ -362,10 +362,17 @@ export const TimeInputBase = forwardRef(
[isControlled, onChange]
);
+ // An empty field starts from today, at the whole minute or second the
+ // wheels show.
const handleSpinnerChange = useCallback(
(parts: { hours: number; minutes: number; seconds?: number }) => {
- const base = value ?? new Date();
- const next = setTimeOfDay(base, parts);
+ const next = value
+ ? setTimeOfDay(value, parts)
+ : setTimeOfDay(new Date(), {
+ ...parts,
+ seconds: parts.seconds ?? 0,
+ milliseconds: 0,
+ });
if (!isWithin(next, lowerBound, max)) return;
commitValue(next);
},
diff --git a/bulma-ui/src/form/__tests__/Calendar.test.tsx b/bulma-ui/src/form/__tests__/Calendar.test.tsx
index e68207508..67876edd3 100644
--- a/bulma-ui/src/form/__tests__/Calendar.test.tsx
+++ b/bulma-ui/src/form/__tests__/Calendar.test.tsx
@@ -2036,3 +2036,44 @@ describe('Calendar focus inside a shadow root', () => {
expect(root.activeElement).toHaveTextContent('2025');
});
});
+
+describe('Calendar and the popover toggle keys', () => {
+ // Alt+ArrowDown and Alt+ArrowUp open and close a picker's popover, so the
+ // calendar leaves them to it in every view rather than moving focus.
+ beforeAll(() => {
+ HTMLElement.prototype.scrollIntoView = jest.fn();
+ });
+ const toggleKeys = (el: Element) =>
+ ['ArrowUp', 'ArrowDown'].map(key =>
+ fireEvent.keyDown(el, { key, altKey: true })
+ );
+
+ it.each([
+ ['the day grid', 'day', '[data-focused="true"]'],
+ ['the month grid', 'month', '[data-focused="true"]'],
+ ['the year list', 'year', '[data-focused-year="true"]'],
+ ] as const)('in %s', (_view, granularity, selector) => {
+ const onFocusedDateChange = jest.fn();
+ const { container } = render(
+
+ );
+ const focused = container.querySelector(selector)!;
+ expect(toggleKeys(focused)).toEqual([true, true]);
+ expect(onFocusedDateChange).not.toHaveBeenCalled();
+ });
+
+ it('in the year list opened from the header', () => {
+ const onFocusedDateChange = jest.fn();
+ const { container } = render(
+
+ );
+ fireEvent.click(container.querySelector('[aria-haspopup="listbox"]')!);
+ const year = container.querySelector('[data-focused-year="true"]')!;
+ expect(toggleKeys(year)).toEqual([true, true]);
+ expect(container.querySelector('[role="listbox"]')).not.toBeNull();
+ expect(onFocusedDateChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/bulma-ui/src/form/__tests__/DateInput.test.tsx b/bulma-ui/src/form/__tests__/DateInput.test.tsx
index bc4b09bb3..9032c071f 100644
--- a/bulma-ui/src/form/__tests__/DateInput.test.tsx
+++ b/bulma-ui/src/form/__tests__/DateInput.test.tsx
@@ -139,10 +139,50 @@ describe('DateInput', () => {
expect(getByRole('dialog')).toBeInTheDocument();
});
- it('opens on ArrowDown', () => {
- const { getByRole } = render( );
- fireEvent.keyDown(getByRole('combobox'), { key: 'ArrowDown' });
+ it('opens on Alt+ArrowDown from the focused field, where ArrowDown steps the year', () => {
+ const handler = jest.fn();
+ const { getByRole, queryByRole } = render(
+
+ );
+ const input = getByRole('combobox') as HTMLInputElement;
+ act(() => {
+ input.focus();
+ });
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ expect(queryByRole('dialog')).toBeNull();
+ expect(input.value).toBe('2025-10-07');
+ fireEvent.keyDown(input, { key: 'ArrowDown', altKey: true });
expect(getByRole('dialog')).toBeInTheDocument();
+ expect(input.value).toBe('2025-10-07');
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ it('closes on Alt+ArrowUp from the calendar, where opening put focus, and moves nothing', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+ const input = getByRole('combobox') as HTMLInputElement;
+ act(() => {
+ input.focus();
+ });
+ act(() => {
+ fireEvent.keyDown(input, { key: 'ArrowDown', altKey: true });
+ });
+ const day = document.activeElement as HTMLElement;
+ expect(day).toHaveTextContent('7');
+ act(() => {
+ fireEvent.keyDown(day, { key: 'ArrowUp', altKey: true });
+ });
+ expect(queryByRole('dialog')).toBeNull();
+ expect(input).toHaveFocus();
+ act(() => {
+ fireEvent.keyDown(input, { key: 'ArrowDown', altKey: true });
+ });
+ expect(document.activeElement).toHaveTextContent('7');
});
it('closes on Escape', () => {
@@ -958,6 +998,19 @@ describe('DateInput launcher icon', () => {
expect(queryByRole('dialog')).toBeNull();
});
+ it('keeps a readOnly field closed on ArrowDown too', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+ const input = getByRole('combobox');
+ act(() => {
+ input.focus();
+ });
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ fireEvent.keyDown(input, { key: 'ArrowDown', altKey: true });
+ expect(queryByRole('dialog')).toBeNull();
+ });
+
it('editable={false} keeps the launcher working (picker-only)', () => {
const { getByLabelText, getByRole } = render(
@@ -1161,6 +1214,130 @@ describe('DateInput native input value handling', () => {
});
describe('DateInputBase remaining branches', () => {
+ it('Enter on the focused day of an empty field picks it at midnight, not at the clock', () => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date(2026, 9, 7, 14, 23, 10, 507));
+ try {
+ const handler = jest.fn();
+ const { getByRole } = render( );
+ act(() => {
+ getByRole('combobox').focus();
+ });
+ const day = document.activeElement as HTMLElement;
+ expect(day).toHaveTextContent('7');
+ act(() => {
+ fireEvent.keyDown(day, { key: 'Enter' });
+ });
+ expect(handler).toHaveBeenLastCalledWith(new Date(2026, 9, 7));
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('Enter picks a day at midnight, as a click does, whatever time the value has', () => {
+ const handler = jest.fn();
+ const { getByRole } = render(
+
+ );
+ act(() => {
+ getByRole('combobox').focus();
+ });
+ act(() => {
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
+ });
+ act(() => {
+ fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
+ });
+ expect(handler).toHaveBeenLastCalledWith(new Date(2026, 9, 8));
+ act(() => {
+ fireEvent.click(
+ Array.from(
+ getByRole('dialog').querySelectorAll('[role="gridcell"]')
+ ).find(c => c.textContent === '10')!
+ );
+ });
+ expect(handler).toHaveBeenLastCalledWith(new Date(2026, 9, 10));
+ });
+
+ it('Enter picks at midnight after typing has moved a value with a time of day', () => {
+ const handler = jest.fn();
+ const { getByRole, getByLabelText } = render(
+
+ );
+ const input = getByRole('combobox');
+ act(() => {
+ input.focus();
+ });
+ fireEvent.keyDown(input, { key: 'ArrowUp' }); // year segment
+ expect(handler).toHaveBeenLastCalledWith(
+ new Date(2027, 9, 7, 14, 30, 45, 250)
+ );
+ act(() => {
+ fireEvent.click(getByLabelText('Choose date'));
+ });
+ act(() => {
+ fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
+ });
+ expect(handler).toHaveBeenLastCalledWith(new Date(2027, 9, 7));
+ });
+
+ it('Enter picks at midnight on an empty field clamped to a max with a time of day', () => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date(2026, 9, 7, 14, 23, 10, 507));
+ try {
+ const handler = jest.fn();
+ const { getByRole } = render(
+
+ );
+ act(() => {
+ getByRole('combobox').focus();
+ });
+ const day = document.activeElement as HTMLElement;
+ expect(day).toHaveTextContent('30');
+ act(() => {
+ fireEvent.keyDown(day, { key: 'Enter' });
+ });
+ expect(handler).toHaveBeenLastCalledWith(new Date(2026, 8, 30));
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('opens an empty field under a min with a time of day on a day it can pick', () => {
+ // Midnight on the min's own day is before the min, so that day can't be
+ // picked; the focus lands on the next one, and Enter picks it at midnight.
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date(2026, 9, 7, 14, 23, 10, 507));
+ try {
+ const handler = jest.fn();
+ const { getByRole } = render(
+
+ );
+ act(() => {
+ getByRole('combobox').focus();
+ });
+ const day = document.activeElement as HTMLElement;
+ expect(day).toHaveTextContent('2');
+ act(() => {
+ fireEvent.keyDown(day, { key: 'Enter' });
+ });
+ expect(handler).toHaveBeenLastCalledWith(new Date(2027, 0, 2));
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
it('controlled value={null} renders an empty input', () => {
const { getByRole } = render( );
expect((getByRole('combobox') as HTMLInputElement).value).toBe('');
diff --git a/bulma-ui/src/form/__tests__/DateRangeInput.test.tsx b/bulma-ui/src/form/__tests__/DateRangeInput.test.tsx
index 1b1df9787..18723e6f1 100644
--- a/bulma-ui/src/form/__tests__/DateRangeInput.test.tsx
+++ b/bulma-ui/src/form/__tests__/DateRangeInput.test.tsx
@@ -421,16 +421,52 @@ describe('DateRangeInput', () => {
expect(onFocusCapture).toHaveBeenCalled();
});
- it('stays closed on focus with openOnFocus off, and opens on ArrowDown', () => {
- render( );
+ it('stays closed on focus with openOnFocus off, and opens on Alt+ArrowDown', () => {
+ const onChange = jest.fn();
+ render(
+
+ );
fireEvent.focus(endInput());
fireEvent.click(endInput());
expect(screen.queryByRole('dialog')).toBeNull();
- // Outside segment mode, as before the input is focused.
- fireEvent.keyDown(screen.getByRole('combobox', { name: 'Start date' }), {
- key: 'ArrowDown',
+ act(() => {
+ startInput().focus();
+ });
+ // A focused input is in segment mode, where ArrowDown steps the year.
+ fireEvent.keyDown(startInput(), { key: 'ArrowDown' });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ expect(lastRange(onChange)).toEqual([new Date(2023, 5, 10), june(12)]);
+ act(() => {
+ fireEvent.keyDown(startInput(), { key: 'ArrowDown', altKey: true });
});
expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('closes on Alt+ArrowUp from the calendar, where opening put focus', () => {
+ render(
+
+ );
+ act(() => {
+ startInput().focus();
+ });
+ act(() => {
+ fireEvent.keyDown(startInput(), { key: 'ArrowDown', altKey: true });
+ });
+ const day = document.activeElement as HTMLElement;
+ expect(day).toHaveAttribute('role', 'gridcell');
+ act(() => {
+ fireEvent.keyDown(day, { key: 'ArrowUp', altKey: true });
+ });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ expect(startInput()).toHaveFocus();
});
it('hands focus back to the input that opened it, and stays shut', () => {
diff --git a/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx b/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx
index 0683a8a3f..1f278537f 100644
--- a/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx
+++ b/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx
@@ -153,7 +153,7 @@ describe('DateTimeInput', () => {
expect(arg.getMinutes()).toBe(45);
});
- it('ArrowDown on the hours wheel increments the hour, keeping the date', () => {
+ it('ArrowUp on the hours wheel increments the hour, keeping the date', () => {
const v = new Date(2024, 5, 7, 10, 0);
const handler = jest.fn();
const { getByRole, getAllByRole } = render(
@@ -161,14 +161,14 @@ describe('DateTimeInput', () => {
);
fireEvent.click(getByRole('combobox'));
fireEvent.click(getByRole('button', { name: /Time/ }));
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
expect(handler).toHaveBeenCalled();
const arg: Date = handler.mock.calls[0][0];
expect(arg.getDate()).toBe(7);
expect(arg.getHours()).toBe(11);
});
- it('ArrowDown on the minutes wheel increments the minute, keeping the hour', () => {
+ it('ArrowUp on the minutes wheel increments the minute, keeping the hour', () => {
const v = new Date(2024, 5, 7, 10, 0);
const handler = jest.fn();
const { getByRole, getAllByRole } = render(
@@ -177,7 +177,7 @@ describe('DateTimeInput', () => {
fireEvent.click(getByRole('combobox'));
fireEvent.click(getByRole('button', { name: /Time/ }));
const wheels = getAllByRole('spinbutton');
- fireEvent.keyDown(wheels[1], { key: 'ArrowDown' });
+ fireEvent.keyDown(wheels[1], { key: 'ArrowUp' });
const arg: Date = handler.mock.calls[0][0];
expect(arg.getHours()).toBe(10);
expect(arg.getMinutes()).toBe(1);
@@ -192,7 +192,7 @@ describe('DateTimeInput', () => {
fireEvent.click(getByRole('combobox'));
// Reveal the wheels and change the hour (13 -> 14).
fireEvent.click(getByRole('button', { name: /Time/ }));
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
// Reset reverts to the value the popover opened with (13:45).
fireEvent.click(getByText('Reset'));
const reverted: Date = handler.mock.calls[handler.mock.calls.length - 1][0];
@@ -922,7 +922,7 @@ describe('DateTimeInputBase remaining branches', () => {
const input = getByRole('combobox') as HTMLInputElement;
fireEvent.click(input);
fireEvent.click(getByRole('button', { name: /Time/ }));
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
expect((handler.mock.calls[0][0] as Date).getHours()).toBe(14);
// The parent did not update `value`, so the text stays at 13:45.
expect(input.value).toBe('2024-06-07 13:45');
@@ -970,8 +970,8 @@ describe('DateTimeInputBase remaining branches', () => {
);
fireEvent.click(getByRole('combobox'));
fireEvent.click(getByRole('button', { name: /Time/ }));
- // ArrowUp decrements to 09:00, which is below min — change is dropped.
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
+ // ArrowDown steps back to 09:00, below min, so the change is dropped.
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
expect(handler).not.toHaveBeenCalled();
expect((getByRole('combobox') as HTMLInputElement).value).toBe(
'2024-06-07 10:00'
@@ -1057,7 +1057,7 @@ describe('DateTimeInputBase remaining branches', () => {
fireEvent.click(getByRole('button', { name: /Time/ }));
const wheels = getAllByRole('spinbutton');
expect(wheels.length).toBe(3);
- fireEvent.keyDown(wheels[2], { key: 'ArrowDown' });
+ fireEvent.keyDown(wheels[2], { key: 'ArrowUp' });
const arg = handler.mock.calls[0][0] as Date;
expect(arg.getSeconds()).toBe(1);
expect(arg.getHours()).toBe(0);
@@ -1097,7 +1097,7 @@ describe('DateTimeInputBase remaining branches', () => {
);
fireEvent.click(getByRole('combobox'));
fireEvent.click(getByRole('button', { name: /Time/ }));
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
expect(handler).toHaveBeenCalled();
expect((handler.mock.calls[0][0] as Date).getHours()).toBe(14);
});
@@ -1372,6 +1372,175 @@ describe('DateTimeInput focus inside the popover', () => {
});
});
+describe('DateTimeInput focus and the time wheels', () => {
+ const v = new Date(2024, 5, 7, 10, 0);
+ const openPopover = (input: HTMLElement) => {
+ act(() => {
+ input.focus();
+ });
+ };
+ const pressTimeButton = (button: HTMLElement) => {
+ act(() => {
+ button.focus();
+ });
+ act(() => {
+ fireEvent.click(button);
+ });
+ };
+
+ it('moves focus to the hours wheel as the Time button opens the wheels', () => {
+ const { getByRole, getAllByRole } = render(
+
+ );
+ openPopover(getByRole('combobox'));
+ pressTimeButton(getByRole('button', { name: /Time/ }));
+ const wheels = getAllByRole('spinbutton');
+ expect(wheels[0]).toHaveAttribute('aria-label', 'hours');
+ expect(wheels[0]).toHaveFocus();
+ });
+
+ it('closes the popover on Alt+ArrowUp from a wheel, turning nothing', () => {
+ const handler = jest.fn();
+ const { getByRole, getAllByRole, queryByRole } = render(
+
+ );
+ openPopover(getByRole('combobox'));
+ pressTimeButton(getByRole('button', { name: /Time/ }));
+ act(() => {
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], {
+ key: 'ArrowUp',
+ altKey: true,
+ });
+ });
+ expect(queryByRole('dialog')).toBeNull();
+ expect(handler).not.toHaveBeenCalled();
+ expect(getByRole('combobox')).toHaveFocus();
+ });
+
+ it('hands focus back to the Time button when Escape closes the wheels', () => {
+ const { getByRole, queryAllByRole, getAllByRole } = render(
+
+ );
+ openPopover(getByRole('combobox'));
+ const timeButton = getByRole('button', { name: /Time/ });
+ pressTimeButton(timeButton);
+ const minutes = getAllByRole('spinbutton')[1];
+ act(() => {
+ minutes.focus();
+ });
+ act(() => {
+ fireEvent.keyDown(minutes, { key: 'Escape' });
+ });
+ expect(queryAllByRole('spinbutton')).toHaveLength(0);
+ expect(timeButton).toHaveFocus();
+ expect(getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('hands focus back to the Time button when it or a click outside the card closes them', () => {
+ const { getByRole, getAllByRole, queryAllByRole, container } = render(
+
+ );
+ openPopover(getByRole('combobox'));
+ const timeButton = getByRole('button', { name: /Time/ });
+ // A click on the button need not focus it, so focus can still be on a
+ // wheel as the button closes them.
+ pressTimeButton(timeButton);
+ expect(getAllByRole('spinbutton')[0]).toHaveFocus();
+ act(() => {
+ fireEvent.click(timeButton);
+ });
+ expect(queryAllByRole('spinbutton')).toHaveLength(0);
+ expect(timeButton).toHaveFocus();
+ // A press on the overlay takes focus off the wheel before the click,
+ // as a browser does for a press on something that takes no focus.
+ pressTimeButton(timeButton);
+ const hours = getAllByRole('spinbutton')[0];
+ expect(hours).toHaveFocus();
+ act(() => {
+ hours.blur();
+ });
+ act(() => {
+ fireEvent.click(container.querySelector('.datetimeinput-time-overlay')!);
+ });
+ expect(queryAllByRole('spinbutton')).toHaveLength(0);
+ expect(timeButton).toHaveFocus();
+ });
+
+ it('leaves focus on Reset when Escape closes the wheels from there', () => {
+ const { getByRole, queryAllByRole } = render(
+
+ );
+ openPopover(getByRole('combobox'));
+ pressTimeButton(getByRole('button', { name: /Time/ }));
+ const reset = getByRole('button', { name: 'Reset' });
+ act(() => {
+ reset.focus();
+ });
+ act(() => {
+ fireEvent.keyDown(reset, { key: 'Escape' });
+ });
+ expect(queryAllByRole('spinbutton')).toHaveLength(0);
+ expect(reset).toHaveFocus();
+ });
+
+ it('takes the calendar the wheels cover out of reach until they close', () => {
+ const { getByRole, getAllByRole } = render(
+
+ );
+ openPopover(getByRole('combobox'));
+ const grid = getByRole('grid');
+ expect(grid.closest('[inert]')).toBeNull();
+ pressTimeButton(getByRole('button', { name: /Time/ }));
+ expect(grid.closest('[inert]')).not.toBeNull();
+ // So the hours wheel is the first tab stop, and Shift+Tab wraps to the
+ // footer rather than reaching a day behind the wheels.
+ act(() => {
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], {
+ key: 'Tab',
+ shiftKey: true,
+ });
+ });
+ expect(getByRole('button', { name: 'Done' })).toHaveFocus();
+ act(() => {
+ fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
+ });
+ expect(grid.closest('[inert]')).toBeNull();
+ });
+
+ it('keeps focus on the wheel the keys turn', () => {
+ const handler = jest.fn();
+ const { getByRole, getAllByRole } = render(
+
+ );
+ openPopover(getByRole('combobox'));
+ pressTimeButton(getByRole('button', { name: /Time/ }));
+ const hours = getAllByRole('spinbutton')[0];
+ act(() => {
+ fireEvent.keyDown(hours, { key: 'ArrowUp' });
+ });
+ act(() => {
+ fireEvent.keyDown(hours, { key: 'ArrowUp' });
+ });
+ expect((handler.mock.lastCall![0] as Date).getHours()).toBe(12);
+ expect(hours).toHaveFocus();
+ });
+
+ it('does the same inline', () => {
+ const { getByRole, getAllByRole, queryAllByRole } = render(
+
+ );
+ const timeButton = getByRole('button', { name: /Time/ });
+ pressTimeButton(timeButton);
+ const hours = getAllByRole('spinbutton')[0];
+ expect(hours).toHaveFocus();
+ act(() => {
+ fireEvent.keyDown(hours, { key: 'Escape' });
+ });
+ expect(queryAllByRole('spinbutton')).toHaveLength(0);
+ expect(timeButton).toHaveFocus();
+ });
+});
+
describe('DateTimeInput focus handed back on close', () => {
// The calendar focuses a cell as the popover opens. Closing must return
// focus to the input, and under openOnFocus (the default) that focus must
@@ -1909,7 +2078,7 @@ describe('DateTimeInput before year 1', () => {
);
fireEvent.click(getByRole('combobox'));
fireEvent.click(getByRole('button', { name: /Time/ }));
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
if (allowed) expect(handler).toHaveBeenCalledWith(at(year, 5, 15, 11, 0));
else expect(handler).not.toHaveBeenCalled();
unmount();
@@ -2027,3 +2196,130 @@ describe('DateTimeInput before year 1', () => {
});
});
});
+
+describe('DateTimeInput times seeded from the clock', () => {
+ // The clock reads 14:23:10.507. A time the picker starts from it keeps
+ // none of what the field does not show: the seconds without enableSeconds,
+ // and the milliseconds always.
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date(2026, 9, 7, 14, 23, 10, 507));
+ });
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+ const last = (handler: jest.Mock) => handler.mock.lastCall![0] as Date;
+ const openOnFocusedDay = (input: HTMLElement) => {
+ act(() => {
+ input.focus();
+ });
+ return document.activeElement as HTMLElement;
+ };
+
+ it('Enter on the focused day of an empty field picks it at midnight', () => {
+ for (const enableSeconds of [false, true]) {
+ const handler = jest.fn();
+ const { getByRole, unmount } = render(
+
+ );
+ const day = openOnFocusedDay(getByRole('combobox'));
+ expect(day).toHaveTextContent('7');
+ act(() => {
+ fireEvent.keyDown(day, { key: 'Enter' });
+ });
+ expect(last(handler)).toEqual(new Date(2026, 9, 7, 0, 0, 0, 0));
+ unmount();
+ }
+ });
+
+ it('a picked day keeps the whole time of a value, by key or by click', () => {
+ const value = new Date(2026, 9, 7, 10, 30, 45, 250);
+ const handler = jest.fn();
+ const { getByRole } = render(
+
+ );
+ const day = openOnFocusedDay(getByRole('combobox'));
+ act(() => {
+ fireEvent.keyDown(day, { key: 'ArrowRight' });
+ });
+ act(() => {
+ fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
+ });
+ expect(last(handler)).toEqual(new Date(2026, 9, 8, 10, 30, 45, 250));
+ const ninth = Array.from(
+ getByRole('dialog').querySelectorAll('[role="gridcell"]')
+ ).find(c => c.textContent === '9')!;
+ act(() => {
+ fireEvent.click(ninth);
+ });
+ expect(last(handler)).toEqual(new Date(2026, 9, 9, 10, 30, 45, 250));
+ });
+
+ it('a value set after mounting empty gives the picked day its time, not the clock', () => {
+ const handler = jest.fn();
+ const { getByRole, rerender } = render(
+
+ );
+ rerender(
+
+ );
+ const day = openOnFocusedDay(getByRole('combobox'));
+ act(() => {
+ fireEvent.keyDown(day, { key: 'Enter' });
+ });
+ expect(last(handler)).toEqual(new Date(2026, 9, 7, 9, 15, 0, 0));
+ });
+
+ it('turning a wheel on an empty field starts from a whole minute', () => {
+ const handler = jest.fn();
+ const { getByRole, getAllByRole } = render(
+
+ );
+ openOnFocusedDay(getByRole('combobox'));
+ act(() => {
+ fireEvent.click(getByRole('button', { name: /Time/ }));
+ });
+ act(() => {
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
+ });
+ expect(last(handler)).toEqual(new Date(2026, 9, 7, 1, 0, 0, 0));
+ });
+
+ it('turning the seconds wheel on an empty field starts from a whole second', () => {
+ const handler = jest.fn();
+ const { getByRole, getAllByRole } = render(
+
+ );
+ openOnFocusedDay(getByRole('combobox'));
+ act(() => {
+ fireEvent.click(getByRole('button', { name: /Time/ }));
+ });
+ act(() => {
+ fireEvent.keyDown(getAllByRole('spinbutton')[2], { key: 'ArrowUp' });
+ });
+ expect(last(handler)).toEqual(new Date(2026, 9, 7, 0, 0, 1, 0));
+ });
+
+ it('typing into an empty field starts from the clock, less what the format hides', () => {
+ for (const [enableSeconds, seeded] of [
+ [false, new Date(2027, 9, 7, 14, 23, 0, 0)],
+ [true, new Date(2027, 9, 7, 14, 23, 10, 0)],
+ ] as const) {
+ const handler = jest.fn();
+ const { getByRole, unmount } = render(
+
+ );
+ const input = getByRole('combobox');
+ act(() => {
+ input.focus();
+ });
+ fireEvent.keyDown(input, { key: 'ArrowUp' }); // year segment
+ expect(last(handler)).toEqual(seeded);
+ unmount();
+ }
+ });
+});
diff --git a/bulma-ui/src/form/__tests__/PickerPopover.test.tsx b/bulma-ui/src/form/__tests__/PickerPopover.test.tsx
index 7e3914621..433017c67 100644
--- a/bulma-ui/src/form/__tests__/PickerPopover.test.tsx
+++ b/bulma-ui/src/form/__tests__/PickerPopover.test.tsx
@@ -68,6 +68,29 @@ describe('PickerPopover', () => {
expect(queryByTestId('inside')).not.toBeNull();
});
+ it('closes on Alt+ArrowUp from inside, but not on a plain ArrowUp', () => {
+ const { getByTestId, queryByTestId } = render( );
+ act(() => {
+ fireEvent.keyDown(getByTestId('inside'), { key: 'ArrowUp' });
+ });
+ expect(queryByTestId('inside')).not.toBeNull();
+ act(() => {
+ fireEvent.keyDown(getByTestId('inside'), {
+ key: 'ArrowUp',
+ altKey: true,
+ });
+ });
+ expect(queryByTestId('inside')).toBeNull();
+ });
+
+ it('keeps Alt+ArrowUp with Escape under closeOnEscape=false', () => {
+ const { queryByTestId } = render( );
+ act(() => {
+ fireEvent.keyDown(document, { key: 'ArrowUp', altKey: true });
+ });
+ expect(queryByTestId('inside')).not.toBeNull();
+ });
+
it('closes on outside pointerdown', () => {
const { queryByTestId, container } = render(
<>
diff --git a/bulma-ui/src/form/__tests__/TimeInput.test.tsx b/bulma-ui/src/form/__tests__/TimeInput.test.tsx
index 19e009e2b..af428e0fe 100644
--- a/bulma-ui/src/form/__tests__/TimeInput.test.tsx
+++ b/bulma-ui/src/form/__tests__/TimeInput.test.tsx
@@ -118,26 +118,26 @@ describe('TimeInput', () => {
expect(getAllByRole('spinbutton').length).toBe(2);
});
- it('ArrowDown on hours wheel increments the hour', () => {
+ it('ArrowUp on hours wheel increments the hour', () => {
const handler = jest.fn();
const { getByRole, getAllByRole } = render(
);
fireEvent.click(getByRole('combobox'));
const hoursWheel = getAllByRole('spinbutton')[0];
- fireEvent.keyDown(hoursWheel, { key: 'ArrowDown' });
+ fireEvent.keyDown(hoursWheel, { key: 'ArrowUp' });
expect(handler).toHaveBeenCalled();
const arg: Date = handler.mock.calls[0][0];
expect(arg.getHours()).toBe(11);
});
- it('ArrowUp on hours wheel decrements the hour', () => {
+ it('ArrowDown on hours wheel decrements the hour', () => {
const handler = jest.fn();
const { getByRole, getAllByRole } = render(
);
fireEvent.click(getByRole('combobox'));
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
const arg: Date = handler.mock.calls[0][0];
expect(arg.getHours()).toBe(9);
});
@@ -228,7 +228,7 @@ describe('TimeInput', () => {
expect(labels).toContain('01');
});
- it('ArrowDown on AM/PM wheel toggles to PM in 12h mode', () => {
+ it('ArrowUp on AM/PM wheel toggles to PM in 12h mode', () => {
const handler = jest.fn();
const { getByRole, getAllByRole } = render(
@@ -237,15 +237,59 @@ describe('TimeInput', () => {
const wheels = getAllByRole('spinbutton');
// hours, minutes, ampm — ampm is last in 12h mode w/o seconds
const ampmWheel = wheels[wheels.length - 1];
- fireEvent.keyDown(ampmWheel, { key: 'ArrowDown' });
+ fireEvent.keyDown(ampmWheel, { key: 'ArrowUp' });
const arg: Date = handler.mock.calls[0][0];
expect(arg.getHours()).toBe(21);
});
- it('ArrowDown opens the popover', () => {
- const { getByRole } = render( );
- fireEvent.keyDown(getByRole('combobox'), { key: 'ArrowDown' });
+ it('Alt+ArrowDown opens the popover from the focused field, and Alt+ArrowUp closes it', () => {
+ const handler = jest.fn();
+ const { getByRole, queryByRole } = render(
+
+ );
+ const input = getByRole('combobox') as HTMLInputElement;
+ act(() => {
+ input.focus();
+ });
+ fireEvent.keyDown(input, { key: 'ArrowDown', altKey: true });
expect(getByRole('dialog')).toBeInTheDocument();
+ act(() => {
+ input.focus();
+ });
+ fireEvent.keyDown(input, { key: 'ArrowUp', altKey: true });
+ expect(queryByRole('dialog')).toBeNull();
+ expect(input.value).toBe('10:00');
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it('Alt+ArrowUp on the wheel opening put focus on closes the popover and turns nothing', () => {
+ const handler = jest.fn();
+ const { getByRole, queryByRole } = render(
+
+ );
+ const input = getByRole('combobox') as HTMLInputElement;
+ act(() => {
+ input.focus();
+ });
+ act(() => {
+ fireEvent.keyDown(input, { key: 'ArrowDown', altKey: true });
+ });
+ const hours = document.activeElement as HTMLElement;
+ expect(hours).toHaveAttribute('role', 'spinbutton');
+ act(() => {
+ fireEvent.keyDown(hours, { key: 'ArrowUp', altKey: true });
+ });
+ expect(queryByRole('dialog')).toBeNull();
+ expect(handler).not.toHaveBeenCalled();
+ expect(input).toHaveFocus();
});
it('Escape closes the popover', () => {
@@ -414,8 +458,8 @@ describe('TimeInput', () => {
);
fireEvent.click(getByRole('combobox'));
const hoursWheel = getAllByRole('spinbutton')[0];
- fireEvent.keyDown(hoursWheel, { key: 'ArrowDown' }); // 11
- fireEvent.keyDown(hoursWheel, { key: 'ArrowDown' }); // 12
+ fireEvent.keyDown(hoursWheel, { key: 'ArrowUp' }); // 11
+ fireEvent.keyDown(hoursWheel, { key: 'ArrowUp' }); // 12
fireEvent.click(getByText('Cancel'));
const last = handler.mock.calls[handler.mock.calls.length - 1][0] as Date;
expect(last.getHours()).toBe(10);
@@ -497,7 +541,7 @@ describe('TimeInput', () => {
/>
);
fireEvent.click(getByRole('combobox'));
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
const arg: Date = handler.mock.calls[0][0];
expect(arg.getHours()).toBe(13);
});
@@ -527,13 +571,13 @@ describe('TimeInput', () => {
expect(getByText('Valider')).toBeInTheDocument();
});
- it('PageDown advances the hour wheel by 5', () => {
+ it('PageUp advances the hour wheel by 5', () => {
const handler = jest.fn();
const { getByRole, getAllByRole } = render(
);
fireEvent.click(getByRole('combobox'));
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'PageDown' });
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'PageUp' });
const arg: Date = handler.mock.calls[handler.mock.calls.length - 1][0];
expect(arg.getHours()).toBe(7);
});
@@ -1245,7 +1289,7 @@ describe('TimeInput mobile footer', () => {
/>
);
fireEvent.click(getByRole('combobox'));
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
expect((handler.mock.calls[0][0] as Date).getHours()).toBe(14);
fireEvent.click(getByText('Reset'));
const reverted = handler.mock.calls[
@@ -1302,7 +1346,7 @@ describe('TimeInputBase remaining branches', () => {
);
const input = getByRole('combobox') as HTMLInputElement;
fireEvent.click(input);
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
expect((handler.mock.calls[0][0] as Date).getHours()).toBe(11);
// The parent did not update `value`, so the text stays at 10:00.
expect(input.value).toBe('10:00');
@@ -1339,7 +1383,7 @@ describe('TimeInputBase remaining branches', () => {
);
fireEvent.click(getByRole('combobox'));
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
const arg = handler.mock.calls[0][0] as Date;
expect(arg.getHours()).toBe(1);
expect(arg.getMinutes()).toBe(0);
@@ -1353,12 +1397,36 @@ describe('TimeInputBase remaining branches', () => {
fireEvent.click(getByRole('combobox'));
const wheels = getAllByRole('spinbutton');
expect(wheels.length).toBe(3);
- fireEvent.keyDown(wheels[2], { key: 'ArrowDown' });
+ fireEvent.keyDown(wheels[2], { key: 'ArrowUp' });
const arg = handler.mock.calls[0][0] as Date;
expect(arg.getSeconds()).toBe(1);
expect(arg.getHours()).toBe(0);
});
+ it('a wheel on an empty field keeps none of the clock it starts from but the date', () => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date(2026, 9, 7, 14, 23, 10, 507));
+ try {
+ for (const [enableSeconds, wheel, expected] of [
+ [false, 0, new Date(2026, 9, 7, 1, 0, 0, 0)],
+ [true, 2, new Date(2026, 9, 7, 0, 0, 1, 0)],
+ ] as const) {
+ const handler = jest.fn();
+ const { getByRole, getAllByRole, unmount } = render(
+
+ );
+ fireEvent.click(getByRole('combobox'));
+ fireEvent.keyDown(getAllByRole('spinbutton')[wheel], {
+ key: 'ArrowUp',
+ });
+ expect(handler).toHaveBeenLastCalledWith(expected);
+ unmount();
+ }
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
it('rejects a wheel change that falls outside min/max', () => {
const handler = jest.fn();
const { getByRole, getAllByRole } = render(
@@ -1370,8 +1438,8 @@ describe('TimeInputBase remaining branches', () => {
/>
);
fireEvent.click(getByRole('combobox'));
- // ArrowUp decrements to 09:00, which is below min — change is dropped.
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
+ // ArrowDown steps back to 09:00, below min, so the change is dropped.
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
expect(handler).not.toHaveBeenCalled();
expect((getByRole('combobox') as HTMLInputElement).value).toBe('10:00');
});
@@ -2006,7 +2074,7 @@ describe('TimeInput before year 1', () => {
);
fireEvent.click(getByRole('combobox'));
- fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' });
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
if (allowed) expect(handler).toHaveBeenCalledWith(inYear(year, 11));
else expect(handler).not.toHaveBeenCalled();
unmount();
diff --git a/bulma-ui/src/form/__tests__/TimeWheels.interactions.test.tsx b/bulma-ui/src/form/__tests__/TimeWheels.interactions.test.tsx
index b77cfb7eb..f90f91cd5 100644
--- a/bulma-ui/src/form/__tests__/TimeWheels.interactions.test.tsx
+++ b/bulma-ui/src/form/__tests__/TimeWheels.interactions.test.tsx
@@ -681,7 +681,28 @@ describe('TimeWheels trackpad wheel accumulation', () => {
});
describe('TimeWheels keyboard and value mapping', () => {
- it('PageUp moves the hours wheel back by 5', () => {
+ it('steps like a spinbutton: ArrowUp and PageUp raise the value, ArrowDown and PageDown lower it', () => {
+ const fn = jest.fn();
+ const { getAllByRole } = render(
+
+ );
+ const hours = getAllByRole('spinbutton')[0];
+ const press = (key: string) => {
+ fireEvent.keyDown(hours, { key });
+ return hours.getAttribute('aria-valuenow');
+ };
+ expect(press('ArrowUp')).toBe('11');
+ expect(fn).toHaveBeenLastCalledWith({ hours: 11, minutes: 0 });
+ expect(press('ArrowDown')).toBe('10');
+ expect(press('ArrowDown')).toBe('9');
+ expect(press('PageUp')).toBe('14');
+ expect(press('PageDown')).toBe('9');
+ // Home and End go to the lowest and highest value, as on a spinbutton.
+ expect(press('Home')).toBe('0');
+ expect(press('End')).toBe('23');
+ });
+
+ it('PageDown moves the hours wheel back by 5', () => {
const handler = jest.fn();
const { wheels } = openPicker(
{
onChange={handler}
/>
);
- fireEvent.keyDown(wheels[0], { key: 'PageUp' });
+ fireEvent.keyDown(wheels[0], { key: 'PageDown' });
expect(lastDate(handler).getHours()).toBe(5);
});
@@ -704,7 +725,7 @@ describe('TimeWheels keyboard and value mapping', () => {
onChange={handler}
/>
);
- fireEvent.keyDown(wheels[0], { key: 'ArrowUp' }); // display 12 → 11 (PM)
+ fireEvent.keyDown(wheels[0], { key: 'ArrowDown' }); // display 12 → 11 (PM)
expect(lastDate(handler).getHours()).toBe(23);
});
@@ -718,12 +739,12 @@ describe('TimeWheels keyboard and value mapping', () => {
onChange={handler}
/>
);
- fireEvent.keyDown(wheels[0], { key: 'ArrowDown' }); // display 12 → 1 (AM)
+ fireEvent.keyDown(wheels[0], { key: 'ArrowUp' }); // display 12 → 1 (AM)
expect(lastDate(handler).getHours()).toBe(1);
expect(lastDate(handler).getMinutes()).toBe(30);
});
- it('ArrowDown on the seconds wheel advances the seconds', () => {
+ it('ArrowUp on the seconds wheel advances the seconds', () => {
const handler = jest.fn();
const { wheels } = openPicker(
{
onChange={handler}
/>
);
- fireEvent.keyDown(wheels[2], { key: 'ArrowDown' });
+ fireEvent.keyDown(wheels[2], { key: 'ArrowUp' });
expect(lastDate(handler).getSeconds()).toBe(31);
});
@@ -747,12 +768,12 @@ describe('TimeWheels keyboard and value mapping', () => {
/>
);
const ampm = getAllByRole('spinbutton')[2];
- fireEvent.keyDown(ampm, { key: 'ArrowDown' });
+ fireEvent.keyDown(ampm, { key: 'ArrowUp' });
expect(fn).toHaveBeenCalledTimes(1);
expect(fn.mock.calls[0][0]).toMatchObject({ hours: 21, minutes: 0 });
// The value prop is uncontrolled here (still 9 AM = index 0), so moving
// back fires onChange(0) — equal to the current ampmIndex → no commit.
- fireEvent.keyDown(ampm, { key: 'ArrowUp' });
+ fireEvent.keyDown(ampm, { key: 'ArrowDown' });
expect(fn).toHaveBeenCalledTimes(1);
});
@@ -833,7 +854,7 @@ describe('TimeWheels keyboard and value mapping', () => {
expect(getByRole('dialog')).toBeInTheDocument();
});
- it('hour and minute changes preserve the seconds part; ArrowUp steps seconds back', () => {
+ it('hour and minute changes preserve the seconds part; ArrowDown steps seconds back', () => {
const handler = jest.fn();
const { wheels } = openPicker(
{
onChange={handler}
/>
);
- fireEvent.keyDown(wheels[0], { key: 'ArrowDown' });
+ fireEvent.keyDown(wheels[0], { key: 'ArrowUp' });
expect(lastDate(handler).getHours()).toBe(11);
expect(lastDate(handler).getSeconds()).toBe(30);
- fireEvent.keyDown(wheels[1], { key: 'ArrowDown' });
+ fireEvent.keyDown(wheels[1], { key: 'ArrowUp' });
expect(lastDate(handler).getMinutes()).toBe(31);
expect(lastDate(handler).getSeconds()).toBe(30);
- fireEvent.keyDown(wheels[2], { key: 'ArrowUp' });
+ fireEvent.keyDown(wheels[2], { key: 'ArrowDown' });
expect(lastDate(handler).getSeconds()).toBe(29);
});
+ it('asks unselectableTimes about whole seconds, without the milliseconds of the clock', () => {
+ jest.useFakeTimers({
+ doNotFake: [
+ 'performance',
+ 'requestAnimationFrame',
+ 'cancelAnimationFrame',
+ ],
+ });
+ jest.setSystemTime(new Date(2026, 9, 7, 14, 23, 10, 507));
+ try {
+ const predicate = jest.fn((d: Date) => d.getMilliseconds() !== 0);
+ const fn = jest.fn();
+ const { getAllByRole } = render(
+
+ );
+ expect(predicate).toHaveBeenCalled();
+ fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowUp' });
+ expect(fn).toHaveBeenLastCalledWith({ hours: 11, minutes: 0 });
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
it('defaults a missing seconds part to 0 when enableSeconds is on', () => {
const fn = jest.fn();
const { getAllByRole } = render(
@@ -863,9 +911,9 @@ describe('TimeWheels keyboard and value mapping', () => {
/>
);
const wheels = getAllByRole('spinbutton');
- fireEvent.keyDown(wheels[0], { key: 'ArrowDown' });
+ fireEvent.keyDown(wheels[0], { key: 'ArrowUp' });
expect(fn).toHaveBeenLastCalledWith({ hours: 11, minutes: 0, seconds: 0 });
- fireEvent.keyDown(wheels[2], { key: 'ArrowDown' });
+ fireEvent.keyDown(wheels[2], { key: 'ArrowUp' });
expect(fn).toHaveBeenLastCalledWith({ hours: 10, minutes: 0, seconds: 1 });
});
diff --git a/bulma-ui/src/form/__tests__/dateUtils.test.ts b/bulma-ui/src/form/__tests__/dateUtils.test.ts
index 137af3eab..2c9483c61 100644
--- a/bulma-ui/src/form/__tests__/dateUtils.test.ts
+++ b/bulma-ui/src/form/__tests__/dateUtils.test.ts
@@ -170,10 +170,19 @@ describe('dateUtils', () => {
});
it('setTimeOfDay leaves omitted parts unchanged', () => {
- const d = setTimeOfDay(new Date(2024, 0, 1, 5, 5, 5), { hours: 9 });
+ const d = setTimeOfDay(new Date(2024, 0, 1, 5, 5, 5, 5), { hours: 9 });
expect(d.getHours()).toBe(9);
expect(d.getMinutes()).toBe(5);
expect(d.getSeconds()).toBe(5);
+ expect(d.getMilliseconds()).toBe(5);
+ });
+
+ it('setTimeOfDay sets the milliseconds too', () => {
+ const d = setTimeOfDay(new Date(2024, 0, 1, 5, 5, 5, 507), {
+ seconds: 0,
+ milliseconds: 0,
+ });
+ expect(d).toEqual(new Date(2024, 0, 1, 5, 5, 0, 0));
});
it('setTimeOfDay leaves hours unchanged when only minutes are given', () => {
diff --git a/bulma-ui/src/form/__tests__/useSegmentedEntry.test.tsx b/bulma-ui/src/form/__tests__/useSegmentedEntry.test.tsx
index 5da80b81b..408994bf8 100644
--- a/bulma-ui/src/form/__tests__/useSegmentedEntry.test.tsx
+++ b/bulma-ui/src/form/__tests__/useSegmentedEntry.test.tsx
@@ -637,6 +637,73 @@ describe('useSegmentedEntry', () => {
expect(input.dataset.open).toBe('true');
});
+ it('opens nothing on ArrowDown when read-only or disabled', () => {
+ for (const props of [{ readOnly: true }, { disabled: true }]) {
+ const { getByTestId, unmount } = render(
+
+ );
+ const input = getByTestId('seg') as HTMLInputElement;
+ focusSeg(input);
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ fireEvent.keyDown(input, { key: 'ArrowDown', altKey: true });
+ expect(input.dataset.open).toBe('false');
+ unmount();
+ }
+ });
+
+ describe('Alt+ArrowDown and Alt+ArrowUp', () => {
+ it('open and close the popover in segment mode without stepping the segment', () => {
+ const onChange = jest.fn();
+ const { getByTestId } = render(
+
+ );
+ const input = getByTestId('seg') as HTMLInputElement;
+ focusSeg(input);
+ expect(input.dataset.inseg).toBe('true');
+ fireEvent.keyDown(input, { key: 'ArrowDown', altKey: true });
+ expect(input.dataset.open).toBe('true');
+ // A second Alt+ArrowDown leaves the open popover alone.
+ fireEvent.keyDown(input, { key: 'ArrowDown', altKey: true });
+ expect(input.dataset.open).toBe('true');
+ fireEvent.keyDown(input, { key: 'ArrowUp', altKey: true });
+ expect(input.dataset.open).toBe('false');
+ // And Alt+ArrowUp leaves the closed one alone.
+ fireEvent.keyDown(input, { key: 'ArrowUp', altKey: true });
+ expect(input.dataset.open).toBe('false');
+ expect(input.value).toBe('13:45');
+ expect(input.dataset.inseg).toBe('true');
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('step nothing when there is no popover', () => {
+ const onChange = jest.fn();
+ const { getByTestId } = render(
+
+ );
+ const input = getByTestId('seg') as HTMLInputElement;
+ focusSeg(input);
+ fireEvent.keyDown(input, { key: 'ArrowDown', altKey: true });
+ fireEvent.keyDown(input, { key: 'ArrowUp', altKey: true });
+ expect(input.dataset.open).toBe('false');
+ expect(input.value).toBe('13:45');
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('open and close the popover in free-form mode', () => {
+ const { getByTestId } = render(
+
+ );
+ const input = getByTestId('seg') as HTMLInputElement;
+ fireEvent.keyDown(input, { key: 'ArrowDown', altKey: true });
+ expect(input.dataset.open).toBe('true');
+ // Plain ArrowUp keeps it open; Alt+ArrowUp closes it.
+ fireEvent.keyDown(input, { key: 'ArrowUp' });
+ expect(input.dataset.open).toBe('true');
+ fireEvent.keyDown(input, { key: 'ArrowUp', altKey: true });
+ expect(input.dataset.open).toBe('false');
+ });
+ });
+
describe('skipKinds', () => {
// Module-level so the hook sees a stable reference, as it asks for.
const SKIP_MINUTES: readonly SegmentKind[] = ['minutes'];
diff --git a/bulma-ui/src/form/_pickerInternals/Calendar.tsx b/bulma-ui/src/form/_pickerInternals/Calendar.tsx
index fbed854d2..128eb922a 100644
--- a/bulma-ui/src/form/_pickerInternals/Calendar.tsx
+++ b/bulma-ui/src/form/_pickerInternals/Calendar.tsx
@@ -19,6 +19,7 @@ import {
DateRangeValue,
DayOfWeek,
PickerLabels,
+ isPopoverToggleKey,
mergeLabels,
} from './pickerTypes';
import {
@@ -514,6 +515,7 @@ export const Calendar: React.FC = ({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
+ if (isPopoverToggleKey(e)) return;
// A key takes the range preview back from the pointer.
setHoverDate(null);
switch (e.key) {
@@ -615,6 +617,7 @@ export const Calendar: React.FC = ({
const handleMonthKeyDown = useCallback(
(e: React.KeyboardEvent) => {
+ if (isPopoverToggleKey(e)) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!isMonthUnselectable(focusedDate)) {
@@ -789,6 +792,7 @@ export const Calendar: React.FC = ({
const handleYearListKeyDown = useCallback(
(e: React.KeyboardEvent) => {
+ if (isPopoverToggleKey(e)) return;
if (isYearGranularity) {
const step = yearStep(e.key, focusedYear, yearList);
if (!step) return;
diff --git a/bulma-ui/src/form/_pickerInternals/PickerPopover.tsx b/bulma-ui/src/form/_pickerInternals/PickerPopover.tsx
index 8d33d3a59..d073bd7b7 100644
--- a/bulma-ui/src/form/_pickerInternals/PickerPopover.tsx
+++ b/bulma-ui/src/form/_pickerInternals/PickerPopover.tsx
@@ -18,6 +18,7 @@ export interface PickerPopoverProps {
className?: string;
trapFocus?: boolean;
closeOnClickOutside?: boolean;
+ /** Close on Escape and on Alt+ArrowUp. Default `true`. */
closeOnEscape?: boolean;
ariaLabel?: string;
ariaLabelledBy?: string;
@@ -102,12 +103,18 @@ export const PickerPopover: React.FC = ({
return () => document.removeEventListener('pointerdown', handler);
}, [isOpen, closeOnClickOutside, anchorRef, onClose]);
+ // Escape closes it, and so does Alt+ArrowUp, the combobox's key for it,
+ // which the calendar and the time wheels inside leave to it.
useEffect(() => {
if (!isOpen || !closeOnEscape || !isBrowser) return undefined;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
+ } else if (e.altKey && e.key === 'ArrowUp') {
+ e.preventDefault();
+ e.stopPropagation();
+ onClose();
}
};
document.addEventListener('keydown', handler);
diff --git a/bulma-ui/src/form/_pickerInternals/TimeWheels.tsx b/bulma-ui/src/form/_pickerInternals/TimeWheels.tsx
index 7d73c2d3e..37a021eeb 100644
--- a/bulma-ui/src/form/_pickerInternals/TimeWheels.tsx
+++ b/bulma-ui/src/form/_pickerInternals/TimeWheels.tsx
@@ -21,7 +21,12 @@ import {
usePrefixedClassNames,
} from '../../helpers/classNames';
import { useConfig } from '../../helpers/Config';
-import { HourFormat, PickerLabels, mergeLabels } from './pickerTypes';
+import {
+ HourFormat,
+ PickerLabels,
+ isPopoverToggleKey,
+ mergeLabels,
+} from './pickerTypes';
import { setTimeOfDay } from './dateUtils';
import { tickHaptic as fireTickHaptic } from './haptics';
import { playAudioTick, unlockAudioTick } from './audioTick';
@@ -394,24 +399,28 @@ const WheelInner = (
[disabled, cancelRaf, commitPosition]
);
+ // The keys are a spinbutton's: up raises the value and down lowers it. The
+ // values run down the wheel, so up brings the one below the band into it,
+ // the way a drag or a scroll that moves the items up does.
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
+ if (isPopoverToggleKey(e)) return;
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
- moveBy(-1);
+ moveBy(1);
break;
case 'ArrowDown':
e.preventDefault();
- moveBy(1);
+ moveBy(-1);
break;
case 'PageUp':
e.preventDefault();
- moveBy(-5);
+ moveBy(5);
break;
case 'PageDown':
e.preventDefault();
- moveBy(5);
+ moveBy(-5);
break;
case 'Home':
e.preventDefault();
@@ -799,6 +808,7 @@ export const TimeWheels: React.FC = ({
hours: h,
minutes: m,
seconds: s,
+ milliseconds: 0,
});
return !unselectableTimes(probe);
},
diff --git a/bulma-ui/src/form/_pickerInternals/dateUtils.ts b/bulma-ui/src/form/_pickerInternals/dateUtils.ts
index 44c023452..615ffbf71 100644
--- a/bulma-ui/src/form/_pickerInternals/dateUtils.ts
+++ b/bulma-ui/src/form/_pickerInternals/dateUtils.ts
@@ -240,14 +240,26 @@ export function addSeconds(d: Date, n: number): Date {
return r;
}
+/**
+ * A copy of `d` with the given parts of its time of day set and the rest
+ * left as they are. A time started from the clock rather than from a value
+ * should pass the seconds its picker does not show, and the milliseconds,
+ * as `0`, or it keeps the ones the clock had.
+ */
export function setTimeOfDay(
d: Date,
- parts: { hours?: number; minutes?: number; seconds?: number }
+ parts: {
+ hours?: number;
+ minutes?: number;
+ seconds?: number;
+ milliseconds?: number;
+ }
): Date {
const r = new Date(d);
if (parts.hours !== undefined) r.setHours(parts.hours);
if (parts.minutes !== undefined) r.setMinutes(parts.minutes);
if (parts.seconds !== undefined) r.setSeconds(parts.seconds);
+ if (parts.milliseconds !== undefined) r.setMilliseconds(parts.milliseconds);
return r;
}
diff --git a/bulma-ui/src/form/_pickerInternals/pickerTypes.ts b/bulma-ui/src/form/_pickerInternals/pickerTypes.ts
index e06a2e56b..3aec3294a 100644
--- a/bulma-ui/src/form/_pickerInternals/pickerTypes.ts
+++ b/bulma-ui/src/form/_pickerInternals/pickerTypes.ts
@@ -5,6 +5,16 @@ export type HourFormat = '12' | '24';
export type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6;
+/**
+ * Whether a key is Alt+ArrowDown or Alt+ArrowUp, which open and close a
+ * picker's popover. The calendar and the time wheels leave both to it rather
+ * than reading them as plain arrows.
+ */
+export const isPopoverToggleKey = (e: {
+ altKey: boolean;
+ key: string;
+}): boolean => e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp');
+
/** The unit a date picker selects: a day, a month or a year. */
export type DateGranularity = 'day' | 'month' | 'year';
diff --git a/bulma-ui/src/form/_pickerInternals/useSegmentedEntry.ts b/bulma-ui/src/form/_pickerInternals/useSegmentedEntry.ts
index 39eba5d19..e424783d3 100644
--- a/bulma-ui/src/form/_pickerInternals/useSegmentedEntry.ts
+++ b/bulma-ui/src/form/_pickerInternals/useSegmentedEntry.ts
@@ -79,15 +79,15 @@ export interface UseSegmentedEntryParams {
readOnly?: boolean;
/** Allow segmented typing. When false, segment mode never engages. */
editable?: boolean;
- /** Whether a popover exists; gates open-on-focus / click / ArrowDown. */
+ /** Whether a popover exists; gates open-on-focus / click / Alt+ArrowDown. */
popover?: boolean;
openOnFocus?: boolean;
closeOnSelect?: boolean;
isOpen: boolean;
/**
* The host's open-state setter. The hook asks it to open on focus, click
- * or ArrowDown, but not on the focus the popover's focus trap hands back
- * to the input as it closes, which is not the user arriving.
+ * or Alt+ArrowDown, but not on the focus the popover's focus trap hands
+ * back to the input as it closes, which is not the user arriving.
*/
setOpen: (next: boolean) => void;
inputRef: React.RefObject;
@@ -386,7 +386,7 @@ export function useSegmentedEntry(
// Clicking the field opens the popover only when `openOnFocus` is on —
// a click is just a focus. With `openOnFocus={false}` (manual-entry
// mode) the click positions the caret for typing and the popover is
- // opened explicitly via the right launcher button (or ArrowDown).
+ // opened explicitly via the right launcher button (or Alt+ArrowDown).
if (openOnFocus && popover && !disabled && !readOnly) setOpen(true);
if (segmentEditable && segmentMap && inputRef.current) {
const caret = inputRef.current.selectionStart ?? 0;
@@ -427,14 +427,28 @@ export function useSegmentedEntry(
const isPm = base.getHours() >= 12;
switch (e.key) {
case 'ArrowUp':
- e.preventDefault();
- typedDigitsRef.current = '';
- applyDateFromSegment(incrementSegmentValue(seg, base, 1, isPm));
- return;
case 'ArrowDown':
e.preventDefault();
+ // The plain arrows step the segment, so the popover opens on
+ // Alt+ArrowDown and closes on Alt+ArrowUp, as a combobox's does,
+ // and neither steps it.
+ if (e.altKey) {
+ if (e.key === 'ArrowUp') {
+ if (isOpen) setOpen(false);
+ } else if (!isOpen && popover) {
+ setOpen(true);
+ }
+ return;
+ }
typedDigitsRef.current = '';
- applyDateFromSegment(incrementSegmentValue(seg, base, -1, isPm));
+ applyDateFromSegment(
+ incrementSegmentValue(
+ seg,
+ base,
+ e.key === 'ArrowUp' ? 1 : -1,
+ isPm
+ )
+ );
return;
case 'ArrowRight':
e.preventDefault();
@@ -514,11 +528,25 @@ export function useSegmentedEntry(
}
}
// ----- free-form / popover key handling -----
- if (e.key === 'ArrowDown' && !isOpen && popover) {
+ // With no segment to step, ArrowDown opens the popover with or without
+ // Alt, unless the field is read-only or disabled, as focus and the
+ // launcher don't open it then either. Alt+ArrowUp closes it.
+ if (
+ e.key === 'ArrowDown' &&
+ !isOpen &&
+ popover &&
+ !disabled &&
+ !readOnly
+ ) {
e.preventDefault();
setOpen(true);
return;
}
+ if (e.key === 'ArrowUp' && e.altKey && isOpen) {
+ e.preventDefault();
+ setOpen(false);
+ return;
+ }
if (e.key === 'Escape' && isOpen) {
e.preventDefault();
setOpen(false);
@@ -552,6 +580,8 @@ export function useSegmentedEntry(
setOpen,
closeOnSelect,
popover,
+ disabled,
+ readOnly,
isUntouched,
tryParse,
text,
diff --git a/docs/docs/api/form/datetime/dateinput.md b/docs/docs/api/form/datetime/dateinput.md
index 4c811ec41..ec3ec43ae 100644
--- a/docs/docs/api/form/datetime/dateinput.md
+++ b/docs/docs/api/form/datetime/dateinput.md
@@ -42,7 +42,7 @@ function example() {
}
```
-**Typing-first** — the same example with `openOnFocus={false}`: focusing or clicking the field lets you type; open the popover with the launcher icon (or press `↓`).
+**Typing-first** — the same example with `openOnFocus={false}`: focusing or clicking the field lets you type; open the popover with the launcher icon (or press `Alt+↓`).
```tsx live
function example() {
@@ -76,7 +76,7 @@ function example() {
}
```
-**Typing-first** — identical, but with `openOnFocus={false}` so focusing just lets you type; the calendar waits behind the launcher icon (or `↓`).
+**Typing-first** — identical, but with `openOnFocus={false}` so focusing just lets you type; the calendar waits behind the launcher icon (or `Alt+↓`).
```tsx live
function example() {
@@ -190,7 +190,7 @@ function example() {
On iOS Safari the calendar lets the user pick any date; `min`/`max` only fire at form-submission validation ([WebKit bug #225639](https://bugs.webkit.org/show_bug.cgi?id=225639), still open). Pass `mobileNative={false}` for iOS-side enforcement. Android Chrome's native picker does honor them.
:::
-**Typing-first** — the same bounds with `openOnFocus={false}`: every keystroke and `↑` / `↓` arrow is clamped to the range (out-of-range candidates are silently rejected), and the launcher icon (or `↓`) opens the calendar.
+**Typing-first** — the same bounds with `openOnFocus={false}`: every keystroke and `↑` / `↓` arrow is clamped to the range (out-of-range candidates are silently rejected), and the launcher icon (or `Alt+↓`) opens the calendar.
```tsx live
function example() {
@@ -227,7 +227,7 @@ Disable specific dates with `shouldDisableDate` (predicate) or `unselectableDate
HTML has no equivalent to `shouldDisableDate` or `unselectableDates`, so the OS-native pickers can't block any dates. This example forces `mobileNative={false}` so the rule works on touch devices too; in your app, keep the default `mobileNative="auto"` and also validate in `onChange`.
:::
-**Typing-first** — with `openOnFocus={false}` the predicate also vetoes manual entry: typing or arrowing to a weekend is rejected, and the calendar stays tucked behind the launcher icon (or `↓`).
+**Typing-first** — with `openOnFocus={false}` the predicate also vetoes manual entry: typing or arrowing to a weekend is rejected, and the calendar stays tucked behind the launcher icon (or `Alt+↓`).
```tsx live
function example() {
@@ -262,7 +262,7 @@ Use an alternative token format. Supported tokens: `YYYY YY MM M DD D HH H hh h
The OS-native pickers use the device's locale format and don't render `placeholder` text. This example forces `mobileNative={false}` so the format/placeholder show on touch devices too.
:::
-**Typing-first** — the same format with `openOnFocus={false}`: type day-first (typing `/` jumps to the next segment) and reach for the launcher icon (or `↓`) when you want the calendar.
+**Typing-first** — the same format with `openOnFocus={false}`: type day-first (typing `/` jumps to the next segment) and reach for the launcher icon (or `Alt+↓`) when you want the calendar.
```tsx live
function example() {
@@ -376,7 +376,7 @@ A clickable launcher sits on the **right** and toggles the popover — handy for
```
-**Typing-first** — the same group with `openOnFocus={false}`, where the launcher icon earns its keep; note that the `triggerIcon={false}` instance has no launcher, so its popover is keyboard-only (`↓`).
+**Typing-first** — the same group with `openOnFocus={false}`, where the launcher icon earns its keep; note that the `triggerIcon={false}` instance has no launcher, so its popover is keyboard-only (`Alt+↓`).
```tsx live
@@ -387,7 +387,7 @@ A clickable launcher sits on the **right** and toggles the popover — handy for
openOnFocus={false}
/>
@@ -414,7 +414,7 @@ Focus the input — the **year** segment highlights automatically and you can dr
These examples use `openOnFocus={false}` so the popover doesn't cover the input — set `openOnFocus={true}` (the default) and both UIs coexist.
:::tip Opening the picker vs. typing
-With `openOnFocus={false}` (used here), **clicking the field just lets you type** — the popover does not appear on focus or click. Open the picker by clicking the **launcher icon on the right** (or pressing `↓`). With the default `openOnFocus={true}`, focusing or clicking the field opens the popover immediately (you can still type while it's open).
+With `openOnFocus={false}` (used here), **clicking the field just lets you type** — the popover does not appear on focus or click. Open the picker by clicking the **launcher icon on the right** (or pressing `Alt+↓`). With the default `openOnFocus={true}`, focusing or clicking the field opens the popover immediately (you can still type while it's open).
:::
#### Basic
@@ -551,7 +551,7 @@ Day and month names follow the supplied BCP-47 locale via `Intl.DateTimeFormat`.
The OS-native pickers always use the device's system locale, so these examples set `mobileNative={false}` to show the per-input `locale` on touch devices too.
:::
-**Typing-first** — the same locales with `openOnFocus={false}` added: type straight into each field, then compare the localized calendars via the launcher icon (or `↓`).
+**Typing-first** — the same locales with `openOnFocus={false}` added: type straight into each field, then compare the localized calendars via the launcher icon (or `Alt+↓`).
```tsx live
@@ -593,7 +593,7 @@ Set `firstDayOfWeek` to align the grid to Monday-first locales.
The OS-native calendars use the device locale for the week start, so `firstDayOfWeek` (and `dayNames`/`monthNames`/`nearbyMonthDays`) are ignored there. This example forces `mobileNative={false}` so the Monday-first grid shows on touch devices too.
:::
-**Typing-first** — the same example with `openOnFocus={false}`: type freely, then open the Monday-first grid with the launcher icon (or `↓`).
+**Typing-first** — the same example with `openOnFocus={false}`: type freely, then open the Monday-first grid with the launcher icon (or `Alt+↓`).
```tsx live
```
-**Typing-first** — every size with `openOnFocus={false}` so focusing just lets you type; the launcher icon (or `↓`) opens the popover.
+**Typing-first** — every size with `openOnFocus={false}` so focusing just lets you type; the launcher icon (or `Alt+↓`) opens the popover.
```tsx live
@@ -686,7 +686,7 @@ If any of these matter, pass `mobileNative={false}` to force the custom calendar
```
-**Typing-first** — the same palette with `openOnFocus={false}`: click in to type, and use the launcher icon (or `↓`) for the calendar.
+**Typing-first** — the same palette with `openOnFocus={false}`: click in to type, and use the launcher icon (or `Alt+↓`) for the calendar.
```tsx live
@@ -747,7 +747,7 @@ function example() {
```
-**Typing-first** — the horizontal layout with `openOnFocus={false}`: focusing lets you type straight away, and the launcher icon (or `↓`) opens the popover.
+**Typing-first** — the horizontal layout with `openOnFocus={false}`: focusing lets you type straight away, and the launcher icon (or `Alt+↓`) opens the popover.
```tsx live
void` | — | Fired when the value changes. |
+| `onChange` | `(d: Date \| null) => void` | — | Fired when the value changes. A day picked in the calendar, by click or by key, arrives at local midnight. |
| `onOpen` | `() => void` | — | Fired when the popover opens. |
| `onClose` | `() => void` | — | Fired when the popover closes. |
| `min` | `Date` | — | Earliest selectable date. A `min` before year 1 is raised to 1 January of year 1, where the range starts without one too: HTML's date and month inputs hold no earlier year, so the calendar and typing stop there. |
@@ -1009,7 +1012,7 @@ Use `inline` instead of the popover when you have vertical room to spare — boo
| `mobileNative` | `boolean` \| `'auto'` | `'auto'` | Use ` ` on coarse-pointer + small-viewport devices. At `'month'` granularity it uses ` ` where the browser implements one, as Chromium browsers, Safari on iOS and Firefox for Android do, and the calendar where it doesn't, as in desktop Firefox. Desktop Safari accepts the type but draws no month control, so forcing `true` there shows a plain text box. HTML has no year input, so `'year'` granularity always renders the calendar. |
| `editable` | `boolean` | `true` | Allow segmented keyboard typing in the input (type the date directly, auto-advancing across segments). `false` makes the field picker-only. |
| `popover` | `boolean` | `true` | Whether the calendar popover exists. `false` makes the field input-only (segmented typing, no popover). |
-| `openOnFocus` | `boolean` | `true` | Open the popover when the input is focused. Focus that a closing popover hands back to the input leaves it closed. Dismissing it commits nothing: an empty field stays empty, and leaving afterwards commits only what was typed since. |
+| `openOnFocus` | `boolean` | `true` | Open the popover when the input is focused. Focus that a closing popover hands back to the input leaves it closed. Dismissing it commits nothing: an empty field stays empty, and leaving afterwards commits only what was typed since. With it off, the launcher or Alt+ArrowDown opens it. |
| `closeOnSelect` | `boolean` | `true` | Close the popover after a date is selected. |
| `position` | `'bottom-left'` \| `'bottom-right'` \| `'top-left'` \| `'top-right'` \| `'auto'` | `'bottom-left'` | Popover anchor position relative to the input. |
| `appendToBody` | `boolean` | `false` | Render the popover into `document.body` via portal. |
diff --git a/docs/docs/api/form/datetime/daterangeinput.md b/docs/docs/api/form/datetime/daterangeinput.md
index 8c42fd773..aca78ea26 100644
--- a/docs/docs/api/form/datetime/daterangeinput.md
+++ b/docs/docs/api/form/datetime/daterangeinput.md
@@ -122,7 +122,7 @@ function Weekdays() {
### Typing the Dates
-Each input has the segmented entry `DateInput` has. With `openOnFocus={false}` the inputs are for typing, `Tab` moves from the start input to the end input, and the launcher or `↓` opens the calendar. An empty end starts from the start date, so you only type what differs.
+Each input has the segmented entry `DateInput` has. With `openOnFocus={false}` the inputs are for typing, `Tab` moves from the start input to the end input, and the launcher or `Alt+↓` opens the calendar; `Alt+↑` closes it. An empty end starts from the start date, so you only type what differs.
```tsx live
`s on coarse-pointer, small-viewport devices (`'auto'`), always (`true`) or never (`false`). The end input's `min` follows the start, and a change goes through the same rules as typing. |
| `editable` | `boolean` | `true` | Allow segmented keyboard typing in both inputs. `false` makes the field picker-only. |
| `popover` | `boolean` | `true` | Whether the calendar popover exists. `false` makes the field input-only. |
-| `openOnFocus` | `boolean` | `true` | Open the popover when focus arrives in the field. Moving focus between the two inputs, or back from the launcher, leaves it closed, and so does the focus a closing popover hands back. A click on either input opens it. |
+| `openOnFocus` | `boolean` | `true` | Open the popover when focus arrives in the field. Moving focus between the two inputs, or back from the launcher, leaves it closed, and so does the focus a closing popover hands back. A click on either input opens it. With it off, the launcher or Alt+ArrowDown opens it. |
| `closeOnSelect` | `boolean` | `true` | Close the popover once the range is picked. The first pick, which only marks the start, never closes it. |
| `position` | `'bottom-left'` \| `'bottom-right'` \| `'top-left'` \| `'top-right'` \| `'auto'` | `'bottom-left'` | Popover anchor position relative to the field. |
| `appendToBody` | `boolean` | `false` | Render the popover into `document.body` via portal. |
diff --git a/docs/docs/api/form/datetime/datetimeinput.md b/docs/docs/api/form/datetime/datetimeinput.md
index 9a1cfde4f..e9b004bc4 100644
--- a/docs/docs/api/form/datetime/datetimeinput.md
+++ b/docs/docs/api/form/datetime/datetimeinput.md
@@ -40,7 +40,7 @@ The popover opens to the calendar with an iOS-style footer — the selected time
```
-**Typing-first** — the same example with `openOnFocus={false}`: focusing or clicking the field lets you type; open the popover with the launcher icon (or press `↓`).
+**Typing-first** — the same example with `openOnFocus={false}`: focusing or clicking the field lets you type; open the popover with the launcher icon (or press `Alt+↓`).
```tsx live
```
-**Typing-first** — the same group with `openOnFocus={false}` on every instance, so clicking a field just lets you type and the launcher icon opens the popover; note the `triggerIcon={false}` instance has no launcher, leaving its popover keyboard-only via `↓`.
+**Typing-first** — the same group with `openOnFocus={false}` on every instance, so clicking a field just lets you type and the launcher icon opens the popover; note the `triggerIcon={false}` instance has no launcher, leaving its popover keyboard-only via `Alt+↓`.
```tsx live
@@ -276,7 +276,7 @@ A clickable launcher sits on the **right** and toggles the popover — handy for
openOnFocus={false}
/>
@@ -315,7 +315,7 @@ function example() {
On iOS Safari the picker UI lets the user pick any value; `min`/`max` only fire at form-submission validation ([WebKit bug #225639](https://bugs.webkit.org/show_bug.cgi?id=225639), still open). Pass `mobileNative={false}` for iOS-side enforcement. Android Chrome's native picker does honor them.
:::
-**Typing-first** — the same bounds with `openOnFocus={false}`: keystrokes and `↑` / `↓` arrows never produce a value outside `min`/`max`, and the launcher icon (or `↓`) opens the popover.
+**Typing-first** — the same bounds with `openOnFocus={false}`: keystrokes and `↑` / `↓` arrows never produce a value outside `min`/`max`, and the launcher icon (or `Alt+↓`) opens the popover.
```tsx live
function example() {
@@ -356,7 +356,7 @@ Blocked dates are disabled in the calendar and rejected during manual typing, th
HTML has no predicate equivalent, so the OS-native pickers can't block any dates. This example forces `mobileNative={false}` so the rule works on touch devices; in your app keep `mobileNative="auto"` and also validate in `onChange`.
:::
-**Typing-first** — the same predicate with `openOnFocus={false}`: a keystroke or arrow that lands on a blocked date is rejected (matching the disabled calendar cells), and the launcher icon (or `↓`) opens the popover.
+**Typing-first** — the same predicate with `openOnFocus={false}`: a keystroke or arrow that lands on a blocked date is rejected (matching the disabled calendar cells), and the launcher icon (or `Alt+↓`) opens the popover.
```tsx live
function example() {
@@ -391,7 +391,7 @@ Blocked times are skipped by the wheels and rejected during manual typing.
Same as Disabled Dates — the OS-native pickers can't evaluate predicates. This example forces `mobileNative={false}` so the blocked hour works on touch devices too.
:::
-**Typing-first** — the same blocked hour with `openOnFocus={false}`: setting the hour segment to a blocked hour is vetoed by the `unselectableTimes` predicate (just as the wheels skip it), and the launcher icon (or `↓`) opens the popover.
+**Typing-first** — the same blocked hour with `openOnFocus={false}`: setting the hour segment to a blocked hour is vetoed by the `unselectableTimes` predicate (just as the wheels skip it), and the launcher icon (or `Alt+↓`) opens the popover.
```tsx live
function example() {
@@ -432,7 +432,7 @@ function example() {
The OS-native calendars use the device locale for the week start, so `firstDayOfWeek` is ignored there. This example forces `mobileNative={false}` so the Monday-first grid shows on touch devices too.
:::
-**Typing-first** — the same Monday-first example with `openOnFocus={false}`: type in the field directly and bring up the popover with the launcher icon (or `↓`) to see the week start.
+**Typing-first** — the same Monday-first example with `openOnFocus={false}`: type in the field directly and bring up the popover with the launcher icon (or `Alt+↓`) to see the week start.
```tsx live
@@ -533,7 +533,7 @@ The OS-native pickers always use the device's system locale, so these examples s
```
-**Typing-first** — every size with `openOnFocus={false}`: clicking any field lets you type straight away, with the launcher icon (or `↓`) opening the popover.
+**Typing-first** — every size with `openOnFocus={false}`: clicking any field lets you type straight away, with the launcher icon (or `Alt+↓`) opening the popover.
```tsx live
@@ -573,7 +573,7 @@ The OS-native pickers always use the device's system locale, so these examples s
```
-**Typing-first** — the same colors with `openOnFocus={false}` on every instance: type directly in any field and open the popover with the launcher icon (or `↓`).
+**Typing-first** — the same colors with `openOnFocus={false}` on every instance: type directly in any field and open the popover with the launcher icon (or `Alt+↓`).
```tsx live
@@ -664,7 +664,7 @@ The single input spans the whole date-time: year → month → day → hours →
These examples use `openOnFocus={false}` so the popover doesn't cover the input.
:::tip Opening the picker vs. typing
-With `openOnFocus={false}` (used here), **clicking the field just lets you type** — the popover does not appear on focus or click. Open the picker by clicking the **launcher icon on the right** (or pressing `↓`). With the default `openOnFocus={true}`, focusing or clicking the field opens the popover immediately (you can still type while it's open).
+With `openOnFocus={false}` (used here), **clicking the field just lets you type** — the popover does not appear on focus or click. Open the picker by clicking the **launcher icon on the right** (or pressing `Alt+↓`). With the default `openOnFocus={true}`, focusing or clicking the field opens the popover immediately (you can still type while it's open).
:::
#### Basic
@@ -757,6 +757,7 @@ A single field spans year → month → day → hours → minutes (→ seconds
| Key | Action |
| --------------------------------- | ------------------------------------------------------------------------------ |
| `↑` / `↓` | Increment / decrement the active segment (wraps in place) |
+| `Alt+↓` / `Alt+↑` | Open / close the popover, leaving the segment as it is |
| `←` / `→` | Move to previous / next segment |
| `0`–`9` | Overwrite the active segment; auto-advances when no further digit is valid |
| `a` / `A` / `p` / `P` | Toggle AM/PM on the meridiem segment (12-hour formats) |
@@ -765,12 +766,13 @@ A single field spans year → month → day → hours → minutes (→ seconds
| `Tab` | Clear segment selection so focus moves out naturally |
| `Escape` | Close the popover |
+In free-form entry there is no segment to step, so a plain `↓` opens the popover as well.
+
### On the popover
| Key | Action |
| --------------------- | ----------------------------------------------- |
-| `↓` | Open popover (when closed) |
-| `Escape` | Close popover |
+| `Escape` / `Alt+↑` | Close popover |
| `←` / `→` | Move focused date by ±1 day |
| `↑` / `↓` | Move focused date by ±7 days |
| `PageUp` / `PageDown` | Move focused date by ±1 month |
@@ -781,7 +783,7 @@ A single field spans year → month → day → hours → minutes (→ seconds
Clicking the month and year in the calendar's header opens the year list to jump to another year. There `←` / `→` move focus by a year, `↑` / `↓` by a row, `Home` / `End` go to the list's ends, and `Enter` / `Space` jump to the focused year. The calendar stays on its month until a year is picked, and `Escape` goes back to it without jumping (a second `Escape` closes the popover).
-Activate the footer **time** button (`Enter` / `Space`) to float the wheels over the calendar. On a time wheel: `↑` / `↓` change the value, `←` / `→` move between the hours / minutes / (seconds) columns, and `Enter` commits and closes. While the wheels are open, `Escape` collapses them (a second `Escape` closes the popover), and clicking anywhere outside the wheel card dismisses them.
+Activate the footer **time** button (`Enter` / `Space`) to float the wheels over the calendar, with focus on the hours wheel. On a time wheel: `↑` / `↓` raise / lower the value, `PageUp` / `PageDown` by 5, `Home` / `End` jump to the lowest / highest, `←` / `→` move between the hours / minutes / (seconds) columns, and `Enter` commits and closes. While the wheels are open, `Escape` collapses them (a second `Escape` closes the popover), and clicking anywhere outside the wheel card dismisses them. The calendar under the wheels is out of reach while they are open, so `Tab` walks the wheels and the footer. Closing them puts focus back on the time button, unless you had moved it on to Reset or ✓, where it stays.
---
@@ -827,7 +829,7 @@ function DateTimeInputFormDemo() {
- Roving `tabindex` keeps a single day focusable at a time, and focus moves with it, inline as in the popover. When the focused date is disabled, that cell is the nearest enabled day of the month.
- Each time wheel uses `role="spinbutton"` with `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, and `aria-valuetext`.
- The footer's confirm button exposes an accessible label (`Done`); the Reset button reverts your edits to the value the popover opened with.
-- Tab order naturally walks from calendar → time wheels → footer (Reset / ✓).
+- Tab order walks from the calendar to the footer (Reset / ✓), and from the time wheels to the footer while they are open.
---
@@ -878,7 +880,7 @@ The DateTimeInput prop set is the union of [DateInput](./dateinput.md) and [Time
| `controlClassName` | `string` | — | Additional CSS classes for the Control wrapper. |
| `value` | `Date` \| `null` | — | Controlled selected date-time. |
| `defaultValue` | `Date` \| `null` | — | Initial value for uncontrolled usage. |
-| `onChange` | `(d: Date \| null) => void` | — | Fired when either the date or time portion changes. |
+| `onChange` | `(d: Date \| null) => void` | — | Fired when either the date or time portion changes. Picking a day keeps the value's whole time of day, down to seconds and milliseconds the field doesn't show, and `min` and `max` judge the picked day at that time. An empty field's day is picked at midnight. |
| `onOpen` | `() => void` | — | Fired when the popover opens. |
| `onClose` | `() => void` | — | Fired when the popover closes. |
| `min` | `Date` | — | Lower bound for the combined date-time. A `min` before year 1 is raised to midnight on 1 January of year 1, where the range starts without one too: HTML's datetime-local input holds no earlier year, so the calendar, the time wheels and typing stop there. |
@@ -893,7 +895,7 @@ The DateTimeInput prop set is the union of [DateInput](./dateinput.md) and [Time
| `mobileNative` | `boolean` \| `'auto'` | `'auto'` | Use ` ` on coarse-pointer devices. |
| `editable` | `boolean` | `true` | Allow segmented keyboard typing (type the date-time directly across all segments). `false` makes the field picker-only. |
| `popover` | `boolean` | `true` | Whether the calendar + time popover exists. `false` makes the field input-only (segmented typing with no popover). Default `true`. |
-| `openOnFocus` | `boolean` | `true` | Open the popover on focus. Default `true`. Focus that a closing popover hands back to the input leaves it closed. Dismissing it commits nothing: an empty field stays empty, and leaving afterwards commits only what was typed since. |
+| `openOnFocus` | `boolean` | `true` | Open the popover on focus. Default `true`. Focus that a closing popover hands back to the input leaves it closed. Dismissing it commits nothing: an empty field stays empty, and leaving afterwards commits only what was typed since. With it off, the launcher or Alt+ArrowDown opens it. |
| `closeOnSelect` | `boolean` | `false` | Off by default — users typically tweak both halves before committing. |
| `position` | `'bottom-left'` \| `'bottom-right'` \| `'top-left'` \| `'top-right'` \| `'auto'` | `'bottom-left'` | Popover anchor position. |
| `appendToBody` | `boolean` | `false` | Render the popover into `document.body` via portal. |
diff --git a/docs/docs/api/form/datetime/timeinput.md b/docs/docs/api/form/datetime/timeinput.md
index 39f1aaa02..bcbd063a6 100644
--- a/docs/docs/api/form/datetime/timeinput.md
+++ b/docs/docs/api/form/datetime/timeinput.md
@@ -38,7 +38,7 @@ import { TimeInput } from '@allxsmith/bestax-bulma';
```
-**Typing-first** — the same example with `openOnFocus={false}`: focusing or clicking the field lets you type; open the popover with the launcher icon (or press `↓`).
+**Typing-first** — the same example with `openOnFocus={false}`: focusing or clicking the field lets you type; open the popover with the launcher icon (or press `Alt+↓`).
```tsx live
@@ -64,7 +64,7 @@ function example() {
}
```
-**Typing-first** — the same controlled example with `openOnFocus={false}`: focus and type freely; the launcher icon on the right (or `↓`) opens the popover.
+**Typing-first** — the same controlled example with `openOnFocus={false}`: focus and type freely; the launcher icon on the right (or `Alt+↓`) opens the popover.
```tsx live
function example() {
@@ -112,7 +112,7 @@ function example() {
The OS-native pickers use the device's clock setting (12h/24h), so `hourFormat` is ignored there. This example forces `mobileNative={false}` so the 12-hour format shows on touch devices too.
:::
-**Typing-first** — identical, but with `openOnFocus={false}` so focusing lets you type (press `a` / `p` on the AM/PM segment); the launcher icon (or `↓`) opens the popover.
+**Typing-first** — identical, but with `openOnFocus={false}` so focusing lets you type (press `a` / `p` on the AM/PM segment); the launcher icon (or `Alt+↓`) opens the popover.
```tsx live
function example() {
@@ -155,7 +155,7 @@ function example() {
Same as above — the OS-native pickers follow the device clock setting. This example forces `mobileNative={false}` to show 24-hour on touch devices.
:::
-**Typing-first** — the same 24-hour example with `openOnFocus={false}`: click in and type the time; the launcher icon (or `↓`) brings up the popover.
+**Typing-first** — the same 24-hour example with `openOnFocus={false}`: click in and type the time; the launcher icon (or `Alt+↓`) brings up the popover.
```tsx live
function example() {
@@ -199,7 +199,7 @@ This example forces `mobileNative={false}` so the seconds wheel shows on every d
**Android Chrome** renders a seconds spinner in its native time picker when `step < 60` (which our component sets when `enableSeconds` is true). **iOS Safari** has no seconds wheel under any circumstances — Apple's native picker UI is hard-locked to hour/minute spinners regardless of `step`. The input value can carry seconds entered programmatically, but iOS users can't pick them in the wheel. If you need a seconds wheel on iOS, pass `mobileNative={false}` to force the custom wheel popover. See [Mobile Native](#mobile-native) below for the full iOS-vs-Android picker support matrix.
:::
-**Typing-first** — the same example plus `openOnFocus={false}`: type across the hours / minutes / seconds segments, and open the popover with the launcher icon (or `↓`).
+**Typing-first** — the same example plus `openOnFocus={false}`: type across the hours / minutes / seconds segments, and open the popover with the launcher icon (or `Alt+↓`).
```tsx live
function example() {
@@ -242,7 +242,7 @@ function example() {
iOS Safari shows every minute regardless of `step`. This example forces `mobileNative={false}` so the 15-minute stepping is enforced on every device.
:::
-**Typing-first** — the same example with `openOnFocus={false}`: increments only step the wheels, not typing — typed values are free-grained — so open the stepped wheels with the launcher icon (or `↓`).
+**Typing-first** — the same example with `openOnFocus={false}`: increments only step the wheels, not typing — typed values are free-grained — so open the stepped wheels with the launcher icon (or `Alt+↓`).
```tsx live
function example() {
@@ -290,7 +290,7 @@ function example() {
On iOS Safari the picker UI lets the user spin to any time; `min`/`max` only fire at form-submission validation ([WebKit bug #225639](https://bugs.webkit.org/show_bug.cgi?id=225639), still open). Pass `mobileNative={false}` for iOS-side enforcement. Android Chrome's native picker does honor them.
:::
-**Typing-first** — the same bounds with `openOnFocus={false}`: typed entry is clamped to the window just like the wheels; open the popover with the launcher icon (or `↓`).
+**Typing-first** — the same bounds with `openOnFocus={false}`: typed entry is clamped to the window just like the wheels; open the popover with the launcher icon (or `Alt+↓`).
```tsx live
function example() {
@@ -336,7 +336,7 @@ function example() {
HTML has no predicate equivalent, so the OS-native pickers can't block any times. This example forces `mobileNative={false}` so the rule works on touch devices; in your app keep `mobileNative="auto"` and also validate in `onChange`.
:::
-**Typing-first** — the same predicate with `openOnFocus={false}`: typing or arrowing into a blocked time is rejected just like in the wheels, and the launcher icon (or `↓`) opens the popover.
+**Typing-first** — the same predicate with `openOnFocus={false}`: typing or arrowing into a blocked time is rejected just like in the wheels, and the launcher icon (or `Alt+↓`) opens the popover.
```tsx live
function example() {
@@ -363,7 +363,7 @@ Focus the input — the **hours** segment highlights automatically and the keybo
These examples use `openOnFocus={false}` so the popover doesn't cover the input — set `openOnFocus={true}` (the default) and both UIs coexist. To turn segment typing off entirely, pass `editable={false}` (picker-only); to drop the popover and keep only the field, pass `popover={false}` (input-only).
:::tip Opening the picker vs. typing
-With `openOnFocus={false}` (used here), **clicking the field just lets you type** — the popover does not appear on focus or click. Open the picker by clicking the **launcher icon on the right** (or pressing `↓`). With the default `openOnFocus={true}`, focusing or clicking the field opens the popover immediately (you can still type while it's open).
+With `openOnFocus={false}` (used here), **clicking the field just lets you type** — the popover does not appear on focus or click. Open the picker by clicking the **launcher icon on the right** (or pressing `Alt+↓`). With the default `openOnFocus={true}`, focusing or clicking the field opens the popover immediately (you can still type while it's open).
:::
#### Basic
@@ -499,7 +499,7 @@ function example() {
}
```
-**Typing-first** — the same example with `openOnFocus={false}`: typing stays silent — the audio thunk and band pulse fire once you open the wheels via the launcher icon (or `↓`) and scroll them.
+**Typing-first** — the same example with `openOnFocus={false}`: typing stays silent — the audio thunk and band pulse fire once you open the wheels via the launcher icon (or `Alt+↓`) and scroll them.
```tsx live
function example() {
@@ -534,7 +534,7 @@ function example() {
}
```
-**Typing-first** — the same auto-routed feedback with `openOnFocus={false}`: the vibrate/thunk fires when you open the wheels with the launcher icon (or `↓`) and scroll them, not while typing.
+**Typing-first** — the same auto-routed feedback with `openOnFocus={false}`: the vibrate/thunk fires when you open the wheels with the launcher icon (or `Alt+↓`) and scroll them, not while typing.
```tsx live
function example() {
@@ -643,7 +643,7 @@ function example() {
}
```
-**Typing-first** — the same token formats with `openOnFocus={false}` on every instance: type into the segments each format defines; the launcher icon (or `↓`) opens the popover.
+**Typing-first** — the same token formats with `openOnFocus={false}` on every instance: type into the segments each format defines; the launcher icon (or `Alt+↓`) opens the popover.
```tsx live
function example() {
@@ -723,7 +723,7 @@ A clickable launcher sits on the **right** and toggles the popover — handy for
```
-**Typing-first** — the same set with `openOnFocus={false}` on each instance: the field is type-first, which makes the right launcher icon (or `↓`) the way into the popover.
+**Typing-first** — the same set with `openOnFocus={false}` on each instance: the field is type-first, which makes the right launcher icon (or `Alt+↓`) the way into the popover.
```tsx live
@@ -734,7 +734,7 @@ A clickable launcher sits on the **right** and toggles the popover — handy for
openOnFocus={false}
/>
@@ -765,7 +765,7 @@ The launcher gives way to a loading spinner at the same right edge, whether the
```
-**Typing-first** — the same sizes with `openOnFocus={false}` on every instance: focusing lets you type, and the launcher icon (or `↓`) opens the popover.
+**Typing-first** — the same sizes with `openOnFocus={false}` on every instance: focusing lets you type, and the launcher icon (or `Alt+↓`) opens the popover.
```tsx live
@@ -805,7 +805,7 @@ The launcher gives way to a loading spinner at the same right edge, whether the
```
-**Typing-first** — the same colors with `openOnFocus={false}` everywhere: click in to type, and use the launcher icon (or `↓`) to open the popover.
+**Typing-first** — the same colors with `openOnFocus={false}` everywhere: click in to type, and use the launcher icon (or `Alt+↓`) to open the popover.
```tsx live
@@ -912,6 +912,7 @@ Focus the input — the **hours** segment is automatically highlighted. Segment
| Key | Action |
| --------------------- | ------------------------------------------------------------------------------ |
| `↑` / `↓` | Increment / decrement the active segment (wraps at boundaries) |
+| `Alt+↓` / `Alt+↑` | Open / close the popover, leaving the segment as it is |
| `←` / `→` | Move to previous / next segment (hour ↔ minute ↔ second ↔ AM/PM) |
| `0`–`9` | Overwrite the active segment; auto-advances when no further digit is valid |
| `a` / `A` / `p` / `P` | Toggle AM/PM on the meridiem segment |
@@ -920,14 +921,21 @@ Focus the input — the **hours** segment is automatically highlighted. Segment
| `Escape` | Close popover |
| `Enter` | Close popover when `closeOnSelect={true}` (value is already committed live) |
+In free-form entry there is no segment to step, so a plain `↓` opens the popover as well.
+
### On a wheel column (when the popover is open)
-| Key | Action |
-| --------------------- | ------------------------------------ |
-| `↑` / `↓` | Increment / decrement focused column |
-| `PageUp` / `PageDown` | Increment / decrement by 5× |
-| `Tab` | Move focus to next column |
-| `Enter` | Commit live value, close popover |
+| Key | Action |
+| --------------------- | ---------------------------------------- |
+| `↑` / `↓` | Increment / decrement focused column |
+| `PageUp` / `PageDown` | Increment / decrement by 5× |
+| `Home` / `End` | Jump to the column's lowest / highest |
+| `←` / `→` | Move focus to the previous / next column |
+| `Tab` | Move focus to next column |
+| `Enter` | Commit live value, close popover |
+| `Escape` / `Alt+↑` | Close popover |
+
+The values run down each wheel, so `↑` brings the value below the band into it, the way dragging the wheel up or scrolling down over it does.
---
@@ -1036,7 +1044,7 @@ Combine `incrementMinutes={5}` (or `15`/`30`) with `min` and `max` to build a ti
| `mobileNative` | `boolean` \| `'auto'` | `'auto'` | Use ` ` on coarse-pointer + small-viewport devices. |
| `editable` | `boolean` | `true` | Allow segmented keyboard typing in the input (type the time directly, auto-advancing across segments). `false` makes the field picker-only. |
| `popover` | `boolean` | `true` | Whether the spinner popover exists. `false` makes the field input-only (segmented typing, no popover). |
-| `openOnFocus` | `boolean` | `true` | Open the popover when the input is focused. Focus that a closing popover hands back to the input leaves it closed. Dismissing it commits nothing: an empty field stays empty, and leaving afterwards commits only what was typed since. |
+| `openOnFocus` | `boolean` | `true` | Open the popover when the input is focused. Focus that a closing popover hands back to the input leaves it closed. Dismissing it commits nothing: an empty field stays empty, and leaving afterwards commits only what was typed since. With it off, the launcher or Alt+ArrowDown opens it. |
| `closeOnSelect` | `boolean` | `false` | Close the popover after a time is selected (off by default). |
| `position` | `'bottom-left'` \| `'bottom-right'` \| `'top-left'` \| `'top-right'` \| `'auto'` | `'bottom-left'` | Popover anchor position relative to the input. |
| `appendToBody` | `boolean` | `false` | Render the popover into `document.body` via portal. On a narrow screen an in-place popover spans the bottom of the viewport, while a portaled one stays on its `position` corner. |