Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ 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

- `@lumx/vue`:
- 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

Expand Down
6 changes: 3 additions & 3 deletions packages/lumx-core/src/js/components/Combobox/Tests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 };
}
188 changes: 188 additions & 0 deletions packages/lumx-core/src/js/components/TimePickerField/Tests.tsx
Original file line number Diff line number Diff line change
@@ -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<string, any>) => 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) => (
<TimePickerField label="Start time" locale="en-US" translations={TRANSLATIONS} {...props} />
);

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);
});
});
}
Loading