diff --git a/bestax-mcp/data/components/DateInput.json b/bestax-mcp/data/components/DateInput.json index 5441f13c8..b1eb0989e 100644 --- a/bestax-mcp/data/components/DateInput.json +++ b/bestax-mcp/data/components/DateInput.json @@ -79,7 +79,7 @@ }, { "title": "Launcher Icon", - "code": "\n \n \n \n \n" + "code": "\n \n \n \n \n" }, { "title": "Launcher Icon", @@ -380,7 +380,7 @@ "name": "onChange", "type": "(d: Date | null) => void", "default": null, - "description": "Fired when the value changes.", + "description": "Fired when the value changes. A day picked in the calendar, by click or by key, arrives at local midnight.", "required": false, "inherited": true, "deprecated": false @@ -524,7 +524,7 @@ "name": "openOnFocus", "type": "boolean", "default": "true", - "description": "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.", + "description": "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.", "required": false, "inherited": true, "deprecated": false diff --git a/bestax-mcp/data/components/DateRangeInput.json b/bestax-mcp/data/components/DateRangeInput.json index e6a5a0c67..4213ccdf2 100644 --- a/bestax-mcp/data/components/DateRangeInput.json +++ b/bestax-mcp/data/components/DateRangeInput.json @@ -387,7 +387,7 @@ "name": "openOnFocus", "type": "boolean", "default": "true", - "description": "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.", + "description": "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.", "required": false, "inherited": true, "deprecated": false diff --git a/bestax-mcp/data/components/DateTimeInput.json b/bestax-mcp/data/components/DateTimeInput.json index b9f274148..2a5b6f3e2 100644 --- a/bestax-mcp/data/components/DateTimeInput.json +++ b/bestax-mcp/data/components/DateTimeInput.json @@ -59,7 +59,7 @@ }, { "title": "Launcher Icon", - "code": "\n \n \n \n \n" + "code": "\n \n \n \n \n" }, { "title": "Launcher Icon", @@ -170,7 +170,7 @@ "code": "" } ], - "accessibility": "- Trigger uses `role=\"combobox\"` with `aria-haspopup=\"dialog\"`, `aria-expanded`, and `aria-controls`.\n- Popover panel has `role=\"dialog\"` with an accessible name. Opening it puts focus on the focused date.\n- Closing the popover with `Escape`, the ✓ button or `Enter` on a time wheel returns focus to the input, whether the input or the launcher opened it. Under `openOnFocus` that returning focus leaves the popover closed; focusing or clicking the input again opens it. Closing it commits nothing by itself: an empty field stays empty, and leaving afterwards commits only what you typed, so seconds the display leaves out are kept.\n- Calendar uses `role=\"grid\"`; cells expose `aria-selected`, `aria-disabled`, and `aria-current=\"date\"`.\n- 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.\n- Each time wheel uses `role=\"spinbutton\"` with `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, and `aria-valuetext`.\n- The footer's confirm button exposes an accessible label (`Done`); the Reset button reverts your edits to the value the popover opened with.\n- Tab order naturally walks from calendar → time wheels → footer (Reset / ✓).", + "accessibility": "- Trigger uses `role=\"combobox\"` with `aria-haspopup=\"dialog\"`, `aria-expanded`, and `aria-controls`.\n- Popover panel has `role=\"dialog\"` with an accessible name. Opening it puts focus on the focused date.\n- Closing the popover with `Escape`, the ✓ button or `Enter` on a time wheel returns focus to the input, whether the input or the launcher opened it. Under `openOnFocus` that returning focus leaves the popover closed; focusing or clicking the input again opens it. Closing it commits nothing by itself: an empty field stays empty, and leaving afterwards commits only what you typed, so seconds the display leaves out are kept.\n- Calendar uses `role=\"grid\"`; cells expose `aria-selected`, `aria-disabled`, and `aria-current=\"date\"`.\n- 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.\n- Each time wheel uses `role=\"spinbutton\"` with `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, and `aria-valuetext`.\n- The footer's confirm button exposes an accessible label (`Done`); the Reset button reverts your edits to the value the popover opened with.\n- Tab order walks from the calendar to the footer (Reset / ✓), and from the time wheels to the footer while they are open.", "related": ["DateInput", "Input", "TimeInput"], "storybook": "https://bestax.io/storybook/?path=/story/form-datetimeinput", "summary": "The `DateTimeInput` combines a calendar and a time **wheel spinner** in a single popover — an iOS-style layout.", @@ -376,7 +376,7 @@ "name": "onChange", "type": "(d: Date | null) => void", "default": null, - "description": "Fired when either the date or time portion changes.", + "description": "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.", "required": false, "inherited": true, "deprecated": false @@ -511,7 +511,7 @@ "name": "openOnFocus", "type": "boolean", "default": "true", - "description": "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.", + "description": "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.", "required": false, "inherited": true, "deprecated": false diff --git a/bestax-mcp/data/components/TimeInput.json b/bestax-mcp/data/components/TimeInput.json index 0950c4348..235099a56 100644 --- a/bestax-mcp/data/components/TimeInput.json +++ b/bestax-mcp/data/components/TimeInput.json @@ -135,7 +135,7 @@ }, { "title": "Launcher Icon", - "code": "\n \n \n \n \n" + "code": "\n \n \n \n \n" }, { "title": "Launcher Icon", @@ -519,7 +519,7 @@ "name": "openOnFocus", "type": "boolean", "default": "true", - "description": "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.", + "description": "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.", "required": false, "inherited": true, "deprecated": false diff --git a/bulma-ui/src/form/DateInput.stories.tsx b/bulma-ui/src/form/DateInput.stories.tsx index 0abb8e3e3..263998519 100644 --- a/bulma-ui/src/form/DateInput.stories.tsx +++ b/bulma-ui/src/form/DateInput.stories.tsx @@ -551,7 +551,7 @@ export const ManualEntryLocale: Story = { docs: { description: { story: - 'The same locales, typing-first — `openOnFocus={false}` keeps the calendar closed until you use the launcher icon or `↓`, so each field takes segmented entry directly.', + 'The same locales, typing-first — `openOnFocus={false}` keeps the calendar closed until you use the launcher icon or `Alt+↓`, so each field takes segmented entry directly.', }, }, }, @@ -567,7 +567,7 @@ export const ManualEntryFirstDayOfWeek: Story = { docs: { description: { story: - 'With `openOnFocus={false}` the Monday-first calendar only opens from the launcher icon or `↓` — focusing the field starts segmented typing instead.', + 'With `openOnFocus={false}` the Monday-first calendar only opens from the launcher icon or `Alt+↓` — focusing the field starts segmented typing instead.', }, }, }, @@ -583,7 +583,7 @@ export const ManualEntryLauncherIcon: Story = { docs: { description: { story: - 'With `openOnFocus={false}` the custom `triggerIconName` launcher (or `↓`) is the only way to open the calendar — clicking into the field just starts typing.', + 'With `openOnFocus={false}` the custom `triggerIconName` launcher (or `Alt+↓`) is the only way to open the calendar — clicking into the field just starts typing.', }, }, }, @@ -617,7 +617,7 @@ export const ManualEntrySizes: Story = { docs: { description: { story: - 'Every size with `openOnFocus={false}` — segmented typing works the same at each scale, and the calendar waits for the launcher icon or `↓`.', + 'Every size with `openOnFocus={false}` — segmented typing works the same at each scale, and the calendar waits for the launcher icon or `Alt+↓`.', }, }, }, @@ -637,7 +637,7 @@ export const ManualEntryColors: Story = { docs: { description: { story: - 'Every color with `openOnFocus={false}` — type into each field directly; the calendar only opens from the launcher icon or `↓`.', + 'Every color with `openOnFocus={false}` — type into each field directly; the calendar only opens from the launcher icon or `Alt+↓`.', }, }, }, @@ -654,7 +654,7 @@ export const ManualEntryHorizontalField: Story = { docs: { description: { story: - 'The horizontal layout with `openOnFocus={false}` — focus drops you straight into segmented typing, and the calendar stays behind the launcher icon or `↓`.', + 'The horizontal layout with `openOnFocus={false}` — focus drops you straight into segmented typing, and the calendar stays behind the launcher icon or `Alt+↓`.', }, }, }, diff --git a/bulma-ui/src/form/DateInputBase.tsx b/bulma-ui/src/form/DateInputBase.tsx index 1026df4f8..86717cc6f 100644 --- a/bulma-ui/src/form/DateInputBase.tsx +++ b/bulma-ui/src/form/DateInputBase.tsx @@ -32,6 +32,7 @@ import { floorMin, isSameDay, isPeriodUnselectable, + startOfDay, startOfPeriod, endOfPeriod, } from './_pickerInternals/dateUtils'; @@ -84,7 +85,10 @@ export interface DateInputBaseProps value?: Date | null; /** Initial date for uncontrolled usage. */ defaultValue?: Date | null; - /** Fired when the value changes. */ + /** + * Fired when the value changes. A day picked in the calendar, by click or + * by key, arrives at local midnight. + */ onChange?: (d: Date | null) => void; /** Fired when the popover opens. */ onOpen?: () => void; @@ -160,7 +164,7 @@ export interface DateInputBaseProps * 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 date is selected. */ @@ -376,8 +380,12 @@ export const DateInputBase = forwardRef( [max, isDayGranularity, granularity] ); + // A pick is the day at midnight, as a click on its cell gives. Enter hands + // over the focused date, which can carry a time of day from the clock, a + // value or a bound, so its time is dropped here. const handleSelect = useCallback( - (d: Date) => { + (picked: Date) => { + const d = startOfDay(picked); if (!isWithin(d, periodMin, periodMax)) return; commitValue(d); setFocusedDate(d); diff --git a/bulma-ui/src/form/DateRangeInput.stories.tsx b/bulma-ui/src/form/DateRangeInput.stories.tsx index 7d8962c6a..3eafca3a0 100644 --- a/bulma-ui/src/form/DateRangeInput.stories.tsx +++ b/bulma-ui/src/form/DateRangeInput.stories.tsx @@ -262,7 +262,7 @@ export const ManualEntry: Story = { docs: { description: { story: - 'With `openOnFocus` off, both inputs are for typing: segments step with the arrow keys and fill from digits, and `Tab` moves from the start to the end. The launcher, or `ArrowDown`, opens the calendar.', + 'With `openOnFocus` off, both inputs are for typing: segments step with the arrow keys and fill from digits, and `Tab` moves from the start to the end. The launcher, or `Alt+ArrowDown`, opens the calendar, and `Alt+ArrowUp` closes it.', }, }, }, diff --git a/bulma-ui/src/form/DateRangeInput.tsx b/bulma-ui/src/form/DateRangeInput.tsx index 0664d2009..8517420e3 100644 --- a/bulma-ui/src/form/DateRangeInput.tsx +++ b/bulma-ui/src/form/DateRangeInput.tsx @@ -138,13 +138,14 @@ export const DateRangeInput = forwardRef( // icon containers have nothing to anchor to, and it renders no Control // inside one or out. The left icon is checked as the caller passed it: // the default glyph is this component's own choice, not a prop the - // caller set. + // caller set, so it goes in only for the advice. const ownControl = rendersOwnControl('DateRangeInput', { insideControl, inline: baseProps.inline, isLoading, iconLeft, iconLeftName: props.iconLeftName, + defaultIconLeftName: iconLeftName, iconLeftSize, iconRight, iconRightName, diff --git a/bulma-ui/src/form/DateRangeInputBase.tsx b/bulma-ui/src/form/DateRangeInputBase.tsx index ca9ba7f4e..5f826ca47 100644 --- a/bulma-ui/src/form/DateRangeInputBase.tsx +++ b/bulma-ui/src/form/DateRangeInputBase.tsx @@ -126,6 +126,7 @@ export interface DateRangeInputBaseProps * 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. */ openOnFocus?: boolean; /** diff --git a/bulma-ui/src/form/DateTimeInput.stories.tsx b/bulma-ui/src/form/DateTimeInput.stories.tsx index 107c5c012..f0f396098 100644 --- a/bulma-ui/src/form/DateTimeInput.stories.tsx +++ b/bulma-ui/src/form/DateTimeInput.stories.tsx @@ -595,7 +595,7 @@ export const ManualEntryFormats: Story = { docs: { description: { story: - 'The same formats, typing-first — `openOnFocus={false}` on each field keeps the popover behind the launcher icon or `↓` while the token formats take segmented typing across the whole field.', + 'The same formats, typing-first — `openOnFocus={false}` on each field keeps the popover behind the launcher icon or `Alt+↓` while the token formats take segmented typing across the whole field.', }, }, }, @@ -611,7 +611,7 @@ export const ManualEntryLauncherIcon: Story = { docs: { description: { story: - 'With `openOnFocus={false}` the custom `triggerIconName` launcher (or `↓`) is the only way to open the popover — clicking into the field just starts typing.', + 'With `openOnFocus={false}` the custom `triggerIconName` launcher (or `Alt+↓`) is the only way to open the popover — clicking into the field just starts typing.', }, }, }, @@ -628,7 +628,7 @@ export const ManualEntryFirstDayOfWeek: Story = { docs: { description: { story: - 'With `openOnFocus={false}` the Monday-first calendar only opens from the launcher icon or `↓` — focusing the field starts segmented typing instead.', + 'With `openOnFocus={false}` the Monday-first calendar only opens from the launcher icon or `Alt+↓` — focusing the field starts segmented typing instead.', }, }, }, @@ -655,7 +655,7 @@ export const ManualEntryLocale: Story = { docs: { description: { story: - 'The same locales, typing-first — `openOnFocus={false}` keeps the popover closed until you use the launcher icon or `↓`, so each field takes segmented entry directly.', + 'The same locales, typing-first — `openOnFocus={false}` keeps the popover closed until you use the launcher icon or `Alt+↓`, so each field takes segmented entry directly.', }, }, }, @@ -689,7 +689,7 @@ export const ManualEntrySizes: Story = { docs: { description: { story: - 'Every size with `openOnFocus={false}` — segmented typing works the same at each scale, and the popover waits for the launcher icon or `↓`.', + 'Every size with `openOnFocus={false}` — segmented typing works the same at each scale, and the popover waits for the launcher icon or `Alt+↓`.', }, }, }, @@ -709,7 +709,7 @@ export const ManualEntryColors: Story = { docs: { description: { story: - 'Every color with `openOnFocus={false}` — type into each field directly; the popover only opens from the launcher icon or `↓`.', + 'Every color with `openOnFocus={false}` — type into each field directly; the popover only opens from the launcher icon or `Alt+↓`.', }, }, }, diff --git a/bulma-ui/src/form/DateTimeInputBase.tsx b/bulma-ui/src/form/DateTimeInputBase.tsx index 4f11e5543..97f13f606 100644 --- a/bulma-ui/src/form/DateTimeInputBase.tsx +++ b/bulma-ui/src/form/DateTimeInputBase.tsx @@ -40,6 +40,7 @@ import { useNativeMobilePicker } from './_pickerInternals/useNativeMobilePicker' import { useSegmentedEntry } from './_pickerInternals/useSegmentedEntry'; import { useControlLoading } from './controlLoading'; import { Icon } from '../elements/Icon'; +import { getActiveElementInTree } from '../helpers/shadowDom'; // The year is padded to four digits, as `datetime-local` requires. HTML has // no such shape for a year before 1, so a date then is empty, as that input @@ -95,7 +96,12 @@ export interface DateTimeInputBaseProps value?: Date | null; /** Initial value for uncontrolled usage. */ defaultValue?: Date | null; - /** Fired when either the date or time portion changes. */ + /** + * 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. + */ onChange?: (d: Date | null) => void; /** Fired when the popover opens. */ onOpen?: () => void; @@ -141,7 +147,7 @@ export interface DateTimeInputBaseProps * 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. + * typed since. With it off, the launcher or Alt+ArrowDown opens it. */ openOnFocus?: boolean; /** Off by default — users typically tweak both halves before committing. */ @@ -415,6 +421,34 @@ export const DateTimeInputBase = forwardRef< if (!open) setTimeOpen(false); }, [open]); + // The footer's Time button opens the wheels, and the hours wheel takes + // focus so the keys turn it straight away. The calendar they cover is + // inert meanwhile, so no tab stop, pointer or screen reader reaches a day + // behind them; the attribute is set on the element, as React 18 has no + // `inert` prop. Closing the wheels hands focus back to the button: focus + // in them would fall to the page as they unmount, and a pointer press + // outside them has already dropped it on the panel or the page. Focus the + // keys moved on to the footer, such as Reset, stays there. + const panelRef = useRef(null); + const calendarRef = useRef(null); + const timeButtonRef = useRef(null); + const timeOverlayRef = useRef(null); + useEffect(() => { + calendarRef.current?.toggleAttribute('inert', timeOpen); + if (!timeOpen) return; + timeOverlayRef.current + ?.querySelector('[role="spinbutton"]') + ?.focus(); + }, [timeOpen]); + const closeTime = useCallback(() => { + const panel = panelRef.current; + const active = getActiveElementInTree(panel); + const movedOn = + !!panel?.contains(active) && !timeOverlayRef.current?.contains(active); + if (!movedOn) timeButtonRef.current?.focus(); + setTimeOpen(false); + }, []); + const commitValue = useCallback( (next: Date | null) => { if (!isControlled) setInternalValue(next); @@ -425,24 +459,36 @@ export const DateTimeInputBase = forwardRef< [isControlled, onChange] ); + // A picked day takes the value's whole time of day, seconds and all, or + // midnight in an empty field. The day handed over by the keyboard is the + // calendar's focused date, which can carry the time of the clock it + // started from, so none of its time is kept. const handleDateSelect = useCallback( (d: Date) => { const merged = setTimeOfDay(d, { hours: value?.getHours() ?? 0, minutes: value?.getMinutes() ?? 0, - seconds: enableSeconds ? (value?.getSeconds() ?? 0) : undefined, + seconds: value?.getSeconds() ?? 0, + milliseconds: value?.getMilliseconds() ?? 0, }); if (!isWithin(merged, lowerBound, max)) return; commitValue(merged); setFocusedDate(d); }, - [value, enableSeconds, lowerBound, max, commitValue] + [value, lowerBound, max, commitValue] ); + // The wheels set the value's time. An empty field starts from the focused + // day, at the whole minute or second the wheels show. const handleTimeChange = useCallback( (parts: { hours: number; minutes: number; seconds?: number }) => { - const base = value ?? focusedDate; - const next = setTimeOfDay(base, parts); + const next = value + ? setTimeOfDay(value, parts) + : setTimeOfDay(focusedDate, { + ...parts, + seconds: parts.seconds ?? 0, + milliseconds: 0, + }); if (!isWithin(next, lowerBound, max)) return; commitValue(next); }, @@ -460,8 +506,16 @@ export const DateTimeInputBase = forwardRef< [parse, defaultFormat, locale] ); - // The Date the user edits when starting without a current value (now). - const makeBaseDate = useCallback((): Date => new Date(), []); + // The Date the user edits when starting without a current value: now, at + // the whole minute, or the whole second with `enableSeconds`. + const makeBaseDate = useCallback( + (): Date => + setTimeOfDay(new Date(), { + seconds: enableSeconds ? undefined : 0, + milliseconds: 0, + }), + [enableSeconds] + ); const inputReadOnlyAttr = !!readOnly || !editable; const canOpen = !!popover && !disabled && !readOnly; @@ -556,45 +610,49 @@ export const DateTimeInputBase = forwardRef< const panel = (
{ // First Escape collapses the floating time wheels; the popover's own // Escape handler (which closes the whole panel) only sees the second. if (timeOpen && e.key === 'Escape') { e.stopPropagation(); - setTimeOpen(false); + closeTime(); } }} >
- +
+ +
{timeOpen && (
{ // 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<