diff --git a/CHANGELOG.md b/CHANGELOG.md index 668563faec..202c6a088a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `@lumx/vue`: - Create the `SelectButton` component +- `@lumx/react`: + - Create the `TimePickerField` component ### Fixed @@ -18,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Include `tsx` components when generating ts declarations - `@lumx/react`, `@lumx/vue`: - `Dialog`, `Popover`: fix focus trap not working when rendered inside a shadow DOM + - `Combobox`: let `Enter` key propagate to submit a surrounding form when the popup is open with no active option ## [4.13.0][] - 2026-05-11 diff --git a/packages/lumx-core/src/js/components/Combobox/Tests.tsx b/packages/lumx-core/src/js/components/Combobox/Tests.tsx index 7c966c6cf1..eea9159b8d 100644 --- a/packages/lumx-core/src/js/components/Combobox/Tests.tsx +++ b/packages/lumx-core/src/js/components/Combobox/Tests.tsx @@ -909,7 +909,7 @@ export default function comboboxTests({ components: { Combobox, IconButton }, re expect(notPrevented).toBe(true); }); - it('should close listbox on Enter when open with no active descendant (and prevent default)', async () => { + it('should close listbox on Enter when open with no active descendant (without preventing default)', async () => { renderWithState(t.inputTemplate); const input = getInput(); await userEvent.click(input); @@ -925,8 +925,8 @@ export default function comboboxTests({ components: { Combobox, IconButton }, re await waitFor(() => { expect(input).toHaveAttribute('aria-expanded', 'false'); }); - // The combobox consumed Enter to close the popup → default IS prevented - expect(notPrevented).toBe(false); + // Default must NOT be prevented — required for the surrounding form to submit on Enter. + expect(notPrevented).toBe(true); }); it('should close listbox on Escape then clear on second Escape', async () => { diff --git a/packages/lumx-core/src/js/components/Combobox/setupCombobox.ts b/packages/lumx-core/src/js/components/Combobox/setupCombobox.ts index 0a2d06a221..46506f8aea 100644 --- a/packages/lumx-core/src/js/components/Combobox/setupCombobox.ts +++ b/packages/lumx-core/src/js/components/Combobox/setupCombobox.ts @@ -205,9 +205,9 @@ export function setupCombobox( } flag = true; } else if (handle.isOpen && !handle.isMultiSelect) { - // Open with no active item (single select) => close the popup. + // Open with no active item (single select) => close the popup, + // but let Enter propagate so it can submit a surrounding form. handle.setIsOpen(false); - flag = true; } // Otherwise (closed popup, or multi-select with no active item), // let Enter pass through so it can submit a surrounding form diff --git a/packages/lumx-core/src/js/components/TimePickerField/Stories.tsx b/packages/lumx-core/src/js/components/TimePickerField/Stories.tsx new file mode 100644 index 0000000000..50fedd64ab --- /dev/null +++ b/packages/lumx-core/src/js/components/TimePickerField/Stories.tsx @@ -0,0 +1,88 @@ +import type { SetupStoriesOptions } from '@lumx/core/stories/types'; + +import { getDateAtTime } from '../../utils/time'; + +const TRANSLATIONS = { + clearLabel: 'Clear', + showSuggestionsLabel: 'Show suggestions', +}; + +/** + * Setup TimePickerField stories for a specific framework (React or Vue). + * + * The framework wrapper (`TimePickerField`) is fully self-contained — it builds + * its option list from `step`/`minTime`/`maxTime`/`locale` internally — so each + * story is just an args object with optional render override. + */ +export function setup({ + component: TimePickerField, + decorators: { withValueOnChange, withCombinations }, +}: SetupStoriesOptions<{ + decorators: 'withValueOnChange' | 'withCombinations'; +}>) { + const meta = { + component: TimePickerField, + args: { + label: 'Time', + locale: 'en-US', + translations: TRANSLATIONS, + }, + decorators: [withValueOnChange()], + }; + + /** Empty field with the default 30-minute step. */ + const Default = {}; + + /** Field with a default value already selected. */ + const WithValue = { + args: { + value: getDateAtTime({ hour: 12, minute: 30 }), + }, + }; + + /** 15-minute interval — produces 96 entries. */ + const Step15 = { + args: { + step: 15, + }, + }; + + /** Disables (but keeps visible) options before 12:00. */ + const WithMinTime = { + args: { + minTime: getDateAtTime({ hour: 2, minute: 0 }), + }, + }; + + /** Disables (but keeps visible) options after 18:00. */ + const WithMaxTime = { + args: { + maxTime: getDateAtTime({ hour: 18, minute: 0 }), + }, + }; + + /** French locale → 24-hour formatting. */ + const French = { + args: { + locale: 'fr-FR', + value: getDateAtTime({ hour: 14, minute: 30 }), + }, + }; + + /** Disabled states (matrix). */ + const Disabled = { + args: WithValue.args, + decorators: [ + withCombinations({ + combinations: { + rows: { + isDisabled: { isDisabled: true }, + 'aria-disabled': { 'aria-disabled': true }, + }, + }, + }), + ], + }; + + return { meta, Default, WithValue, Step15, WithMinTime, WithMaxTime, French, Disabled }; +} diff --git a/packages/lumx-core/src/js/components/TimePickerField/Tests.tsx b/packages/lumx-core/src/js/components/TimePickerField/Tests.tsx new file mode 100644 index 0000000000..6dbfa3b6e8 --- /dev/null +++ b/packages/lumx-core/src/js/components/TimePickerField/Tests.tsx @@ -0,0 +1,188 @@ +import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/dom'; +import { vi } from 'vitest'; + +import { getDateAtTime } from '../../utils/time'; + +export const TRANSLATIONS = { + clearLabel: 'Clear', + showSuggestionsLabel: 'Show suggestions', +}; + +type RenderResult = { unmount: () => void; container: HTMLElement }; + +/** + * Options to set up the TimePickerField test suite. + * Injected by the framework-specific test file (React or Vue). + */ +export interface TimePickerFieldTestSetup { + components: { + TimePickerField: any; + }; + /** + * Render a TimePickerField template with controlled state management. + * + * @param template JSX render function receiving `{ value, onChange, ... }`. + * @param initialArgs Initial props (value, spies, etc.). + */ + renderWithState: (template: (props: any) => any, initialArgs?: Record) => RenderResult; +} + +/** Asymmetric matcher for a Date with the given hour and minute (time-of-day). */ +const expectTimeOfDay = (hours: number, minutes: number) => + expect.toSatisfy((d: unknown) => d instanceof Date && d.getHours() === hours && d.getMinutes() === minutes); + +export function createTemplates(TimePickerField: any) { + /** Default single time-picker template */ + const defaultTemplate = (props: any) => ( + + ); + + return { defaultTemplate }; +} + +// ═══════════════════════════════════════════════════════════════════ +// Main test suite +// ═══════════════════════════════════════════════════════════════════ + +export default function timePickerFieldTests({ components, renderWithState }: TimePickerFieldTestSetup) { + const { TimePickerField } = components; + const { defaultTemplate } = createTemplates(TimePickerField); + + describe('Static rendering', () => { + it('renders the field with the provided label', () => { + renderWithState(defaultTemplate, { label: 'Start' }); + expect(screen.getByRole('combobox', { name: 'Start' })).toBeInTheDocument(); + }); + + it('displays the formatted current value', () => { + renderWithState(defaultTemplate, { value: getDateAtTime({ hour: 14, minute: 30 }) }); + // en-US short time. Node ICU may use a narrow no-break space (U+202F) between time and AM/PM. + const inputValue = (screen.getByRole('combobox') as HTMLInputElement).value; + expect(inputValue).toMatch(/^2:30/); + expect(inputValue.toUpperCase()).toContain('PM'); + }); + }); + + describe('option list', () => { + it('builds 48 entries for the default 30-minute step', async () => { + renderWithState(defaultTemplate); + await userEvent.click(screen.getByRole('combobox')); + expect(screen.queryAllByRole('option')).toHaveLength(48); + }); + + it('honours `step=15`', async () => { + renderWithState(defaultTemplate, { step: 15 }); + await userEvent.click(screen.getByRole('combobox')); + expect(screen.queryAllByRole('option')).toHaveLength(96); + }); + + it('keeps options before `minTime` visible but disables them', async () => { + renderWithState(defaultTemplate, { minTime: getDateAtTime({ hour: 10, minute: 0 }) }); + await userEvent.click(screen.getByRole('combobox')); + const options = screen.queryAllByRole('option'); + // Full 48-entry list is still rendered. + expect(options).toHaveLength(48); + // Find option for 9:30 → must be disabled. 10:00 → enabled. + const optBefore = options.find((o) => /9:30/.test(o.textContent ?? '')); + const optAfter = options.find((o) => /10:00/.test(o.textContent ?? '')); + expect(optBefore).toHaveAttribute('aria-disabled', 'true'); + expect(optAfter).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('keeps options after `maxTime` visible but disables them', async () => { + renderWithState(defaultTemplate, { maxTime: getDateAtTime({ hour: 1, minute: 0 }) }); + await userEvent.click(screen.getByRole('combobox')); + const options = screen.queryAllByRole('option'); + expect(options).toHaveLength(48); + // 1:00 enabled, 1:30 disabled. + const optAt = options.find((o) => /1:00/.test(o.textContent ?? '')); + const optAfter = options.find((o) => /1:30/.test(o.textContent ?? '')); + expect(optAt).not.toHaveAttribute('aria-disabled', 'true'); + expect(optAfter).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + describe('typed input parsing', () => { + it('parses a typed time on blur and emits the new Date', async () => { + const onChange = vi.fn(); + renderWithState(defaultTemplate, { value: getDateAtTime({ hour: 8, minute: 0 }), onChange }); + const input = screen.getByRole('combobox'); + await userEvent.click(input); + await userEvent.clear(input); + await userEvent.type(input, '10:30'); + // Blur the input. + await userEvent.tab(); + expect(onChange).toHaveBeenLastCalledWith(expectTimeOfDay(10, 30), undefined, undefined); + }); + + it('parses a typed time on blur even when it is not in the option list', async () => { + const onChange = vi.fn(); + renderWithState(defaultTemplate, { value: getDateAtTime({ hour: 8, minute: 0 }), onChange }); + const input = screen.getByRole('combobox') as HTMLInputElement; + await userEvent.click(input); + await userEvent.clear(input); + await userEvent.type(input, '10:33'); + await userEvent.tab(); + expect(onChange).toHaveBeenLastCalledWith(expectTimeOfDay(10, 33), undefined, undefined); + expect(input.value).toEqual('10:33 AM'); + }); + + it('parses a typed time on blur when starting from an empty field (no initial value)', async () => { + const onChange = vi.fn(); + renderWithState(defaultTemplate, { onChange }); + const input = screen.getByRole('combobox') as HTMLInputElement; + await userEvent.click(input); + await userEvent.type(input, '1:01'); + await userEvent.tab(); + expect(onChange).toHaveBeenLastCalledWith(expectTimeOfDay(1, 1), undefined, undefined); + expect(input.value).toEqual('1:01 AM'); + + onChange.mockClear(); + // Focus and blur again to check if the value keeps the same + await userEvent.click(input); + await userEvent.tab(); + expect(onChange).not.toHaveBeenCalled(); + expect(input.value).toEqual('1:01 AM'); + }); + + it('snaps to `minTime` when the typed input is below it', async () => { + const onChange = vi.fn(); + renderWithState(defaultTemplate, { + value: getDateAtTime({ hour: 13, minute: 0 }), + minTime: getDateAtTime({ hour: 12, minute: 0 }), + onChange, + }); + const input = screen.getByRole('combobox'); + await userEvent.click(input); + await userEvent.clear(input); + await userEvent.type(input, '9'); + await userEvent.tab(); + expect(onChange).toHaveBeenLastCalledWith(expectTimeOfDay(12, 0), undefined, undefined); + }); + + it('does not call onChange for an unparseable typed value', async () => { + const onChange = vi.fn(); + renderWithState(defaultTemplate, { value: getDateAtTime({ hour: 8, minute: 0 }), onChange }); + const input = screen.getByRole('combobox'); + await userEvent.click(input); + await userEvent.clear(input); + await userEvent.type(input, 'xyz'); + await userEvent.tab(); + // The implicit `clear` may emit `onChange(undefined, ...)`, but no Date should ever be emitted. + expect(onChange).not.toHaveBeenCalledWith(expect.any(Date), undefined, undefined); + }); + }); + + describe('selection via click', () => { + it('emits a Date matching the picked option time-of-day', async () => { + const onChange = vi.fn(); + renderWithState(defaultTemplate, { value: getDateAtTime({ hour: 8, minute: 0 }), onChange }); + await userEvent.click(screen.getByRole('combobox')); + // Match "8:00 PM" (any whitespace between time and AM/PM — ICU may use U+202F). + const option = await screen.findByRole('option', { name: /^8:00\sPM$/i }); + await userEvent.click(option); + expect(onChange).toHaveBeenLastCalledWith(expectTimeOfDay(20, 0), undefined, undefined); + }); + }); +} diff --git a/packages/lumx-core/src/js/components/TimePickerField/index.tsx b/packages/lumx-core/src/js/components/TimePickerField/index.tsx new file mode 100644 index 0000000000..dd11d18f3a --- /dev/null +++ b/packages/lumx-core/src/js/components/TimePickerField/index.tsx @@ -0,0 +1,97 @@ +import { classNames } from '../../utils'; +import type { GenericProps, HasClassName, HasTheme, LumxClassName } from '../../types'; +import type { TimeOfDay } from '../../utils/time'; +import type { SelectTextFieldTranslations } from '../../utils/select/types'; + +/** + * Component display name. + */ +export const COMPONENT_NAME = 'TimePickerField'; + +/** + * Component default class name (BEM root). + */ +export const CLASSNAME: LumxClassName = 'lumx-time-picker-field'; +const { block } = classNames.bem(CLASSNAME); + +/** + * Translations consumed by `TimePickerField` (forwarded as-is to the underlying + * `SelectTextField`). + */ +export type TimePickerFieldTranslations = Pick; + +/** + * Core props for the `TimePickerField` template. + */ +export interface TimePickerFieldProps extends HasTheme, HasClassName, GenericProps { + /** + * Currently selected option. Resolve from the consumer's `Date` value via + * `value && timeList.find((e) => e.hour === value.getHours() && e.minute === value.getMinutes())`. + */ + value?: TimeOfDay; + + /** Time options (one entry per `step`-minute slot). */ + options: TimeOfDay[]; + + /** Translation labels (clear button, show-suggestions toggle). */ + translations: TimePickerFieldTranslations; + + /** Called when the user picks (or clears) an option. */ + handleChange(next: TimeOfDay | undefined): void; + + /** Called as the user types — wrappers track this to resolve on blur. */ + handleSearch(typed: string): void; + + /** Called when the input loses focus — wrappers commit the typed value. */ + handleBlur(): void; +} + +/** + * Injected framework-specific components for TimePickerField rendering. + */ +export interface TimePickerFieldComponents { + /** Framework-specific `SelectTextField` wrapper. */ + SelectTextField: any; + /** Framework-specific `SelectTextField.Option` sub-component (used to render disabled out-of-range entries). */ + Option: any; +} + +/** + * `TimePickerField` core template. + * + * Renders a `SelectTextField` with an option list built from + * `buildTimeList`. Out-of-range options (strictly before `minTime` / after + * `maxTime`) remain visible in the dropdown but are rendered as disabled. + * + * Framework-specific components are passed as a second argument by the + * React/Vue wrappers. + * + * @param props Component props. + * @param components Injected framework-specific components. + * @return JSX element. + */ +export const TimePickerField = ( + props: TimePickerFieldProps, + { SelectTextField, Option }: TimePickerFieldComponents, +) => { + const { options, translations, className, handleChange, handleSearch, handleBlur, ...fowardedProps } = props; + + return ( +