Compare commits

..

8 Commits

Author SHA1 Message Date
4f59bda937 Update dependency @storybook/addon-a11y to v10.2.12
Some checks failed
continuous-integration/drone/pr Build is failing
2026-02-24 22:09:28 +00:00
1523f7be2c add unit tests
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 17:55:26 +01:00
b664c99944 fix sonar issues
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 16:27:53 +01:00
3d4a4a5f57 rewrite datepicker, v0.1.18
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
2026-02-24 16:22:12 +01:00
f9864842b5 fix sonar bugs
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 12:18:55 +01:00
dd084369e9 update thresholds
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 12:10:36 +01:00
850eed0766 update ci
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 12:06:43 +01:00
4904bea29c update unit tests coverage
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 11:51:08 +01:00
20 changed files with 3253 additions and 77 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@panic/web-ui",
"version": "0.1.17",
"version": "0.1.18",
"license": "AGPL-3.0-only",
"description": "Core components for panic.haus web applications",
"type": "module",
@@ -90,5 +90,8 @@
"vite": "^7.0.0",
"vitest": "^4.0.18",
"yjs": "^13.6.24"
},
"dependencies": {
"@types/node": "^25.3.0"
}
}

View File

@@ -11,7 +11,7 @@ const meta = {
docs: {
description: {
component:
'Date selection field with InputField-compatible API, supporting date/time/datetime-local values, size/layout variants, and validation state.',
'In-house date/time selection field with InputField-compatible API. Uses a custom popup (not native browser pickers) and supports date, time, and date-time modes.',
},
},
},
@@ -27,10 +27,10 @@ const meta = {
table: { type: { summary: 'string' } },
},
type: {
description: 'Native date input type.',
options: ['date', 'datetime-local', 'time'],
description: 'DatePicker mode.',
options: ['date', 'date-time', 'time'],
control: 'inline-radio',
table: { type: { summary: "'date' | 'datetime-local' | 'time'" } },
table: { type: { summary: "'date' | 'date-time' | 'time'" } },
},
size: {
description: 'Input size.',
@@ -55,6 +55,22 @@ const meta = {
control: 'text',
table: { type: { summary: 'string' } },
},
format: {
description:
'Optional input/output format. Supported tokens: `dd`, `mm`, `yyyy`, `HH` (for example `dd/mm/yyyy HH:mm`).',
control: 'text',
table: { type: { summary: 'string' } },
},
min: {
description: 'Optional minimum value in the same format as `value`.',
control: 'text',
table: { type: { summary: 'string' } },
},
max: {
description: 'Optional maximum value in the same format as `value`.',
control: 'text',
table: { type: { summary: 'string' } },
},
name: {
description: 'Native input `name` attribute.',
control: 'text',
@@ -108,7 +124,7 @@ const meta = {
},
args: {
label: 'Schedule at',
type: 'datetime-local',
type: 'date-time',
value: '',
size: 'md',
width: 'md',
@@ -124,8 +140,8 @@ export const DateOnly: Story = {
type: 'date',
label: 'Publish date',
},
render: (args) => {
const [value, setValue] = useState('2031-05-20');
render: function DateOnlyRender(args) {
const [value, setValue] = useState('2031/05/20');
return (
<DatePicker
{...args}
@@ -141,11 +157,11 @@ export const DateOnly: Story = {
export const DateTime: Story = {
args: {
type: 'datetime-local',
type: 'date-time',
label: 'Schedule at',
},
render: (args) => {
const [value, setValue] = useState('2031-05-20T14:30');
render: function DateTimeRender(args) {
const [value, setValue] = useState('2031/05/20 14:30');
return (
<DatePicker
{...args}
@@ -167,7 +183,7 @@ export const TimeOnlyInline: Story = {
size: 'sm',
rightIcon: <CalendarDaysIcon className="h-4 w-4 ui-body-secondary" />,
},
render: (args) => {
render: function TimeOnlyInlineRender(args) {
const [value, setValue] = useState('09:00');
return (
<DatePicker
@@ -182,9 +198,10 @@ export const TimeOnlyInline: Story = {
},
};
export const Error: Story = {
export const ErrorState: Story = {
name: 'Error',
args: {
type: 'datetime-local',
type: 'date-time',
label: 'Schedule at',
value: '',
error: 'Pick a valid future date and time',
@@ -193,20 +210,20 @@ export const Error: Story = {
export const Disabled: Story = {
args: {
type: 'datetime-local',
type: 'date-time',
label: 'Published at',
value: '2031-05-20T14:30',
value: '2031/05/20 14:30',
disabled: true,
},
};
export const SizeMatrix: Story = {
args: {
type: 'datetime-local',
type: 'date-time',
label: 'Schedule at',
},
render: (args) => {
const [value, setValue] = useState('2031-05-20T14:30');
render: function SizeMatrixRender(args) {
const [value, setValue] = useState('2031/05/20 14:30');
return (
<div className="grid grid-cols-1 gap-3">
<DatePicker
@@ -238,3 +255,26 @@ export const SizeMatrix: Story = {
);
},
};
export const CustomFormatWithRange: Story = {
args: {
type: 'date-time',
label: 'Starts at',
format: 'dd/mm/yyyy HH:mm',
min: '10/03/2026 09:00',
max: '24/03/2026 18:30',
},
render: function CustomFormatWithRangeRender(args) {
const [value, setValue] = useState('22/03/2026 14:30');
return (
<DatePicker
{...args}
value={value}
onChange={(event) => {
setValue(event.target.value);
args.onChange?.(event);
}}
/>
);
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -99,7 +99,7 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Stacked: Story = {
render: (args) => {
render: function StackedRender(args) {
const [value, setValue] = useState(args.value);
return (
<Dropdown
@@ -119,7 +119,7 @@ export const Inline: Story = {
layout: 'inline',
size: 'sm',
},
render: (args) => {
render: function InlineRender(args) {
const [value, setValue] = useState(args.value);
return (
<Dropdown
@@ -147,7 +147,7 @@ export const WithError: Story = {
};
export const SizeMatrix: Story = {
render: (args) => {
render: function SizeMatrixRender(args) {
const [value, setValue] = useState(args.value);
return (
<div className="grid grid-cols-1 gap-3">

View File

@@ -67,7 +67,7 @@ export const Basic: Story = {
};
export const WithActions: Story = {
render: (args) => {
render: function WithActionsRender(args) {
const [title, setTitle] = useState('Storybook powered CMS');
const [status, setStatus] = useState('draft');

View File

@@ -127,7 +127,7 @@ export const Text: Story = {
label: 'Title',
placeholder: 'Write a title',
},
render: (args) => {
render: function TextRender(args) {
const [value, setValue] = useState('Storybook integration');
return (
<InputField
@@ -148,7 +148,7 @@ export const PasswordWithToggle: Story = {
label: 'Password',
placeholder: 'Type a strong password',
},
render: (args) => {
render: function PasswordWithToggleRender(args) {
const [value, setValue] = useState('pa55word');
return (
<InputField
@@ -171,7 +171,7 @@ export const InlineWithIcon: Story = {
size: 'sm',
rightIcon: <MagnifyingGlassIcon className="h-4 w-4 ui-body-secondary" />,
},
render: (args) => {
render: function InlineWithIconRender(args) {
const [value, setValue] = useState('posts');
return (
<InputField
@@ -186,7 +186,8 @@ export const InlineWithIcon: Story = {
},
};
export const Error: Story = {
export const ErrorState: Story = {
name: 'Error',
args: {
type: 'email',
label: 'Email',
@@ -210,7 +211,7 @@ export const SizeMatrix: Story = {
label: 'Name',
placeholder: 'Enter value',
},
render: (args) => {
render: function SizeMatrixRender(args) {
const [value, setValue] = useState('Beatrice');
return (
<div className="grid grid-cols-1 gap-3">

View File

@@ -53,7 +53,8 @@ type Story = StoryObj<typeof meta>;
export const Body: Story = {};
export const Error: Story = {
export const ErrorState: Story = {
name: 'Error',
args: {
variant: 'error',
children: 'This field is required',

View File

@@ -115,7 +115,7 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Editable: Story = {
render: (args) => {
render: function EditableRender(args) {
const [markdown, setMarkdown] = useState(args.markdown);
return (
<div className="w-full max-w-2xl">

View File

@@ -183,7 +183,7 @@ export const Empty: Story = {
};
export const InteractiveSortingAndPagination: Story = {
render: () => {
render: function InteractiveSortingAndPaginationRender() {
const [sorting, setSorting] = useState<SortState | null>({
field: 'name',
direction: 'asc',

View File

@@ -10,6 +10,7 @@
--bg-page: #16121a;
--surface-bg: rgba(24, 24, 27, 0.45);
--surface-bg-strong: rgba(24, 24, 27, 0.62);
--datepicker-menu-bg: #18181b;
--surface-border: rgba(82, 82, 91, 0.6);
--surface-divider: rgba(63, 63, 70, 0.85);
--text-primary: #d5cfdf;
@@ -59,6 +60,7 @@
--bg-page: #f7f7fb;
--surface-bg: rgba(255, 255, 255, 0.9);
--surface-bg-strong: rgba(255, 255, 255, 0.98);
--datepicker-menu-bg: #ffffff;
--surface-border: rgba(161, 161, 170, 0.45);
--surface-divider: rgba(212, 212, 216, 0.9);
--text-primary: #52485c;

View File

@@ -327,3 +327,190 @@
color: var(--text-secondary);
@apply px-4 py-3 text-sm;
}
.datepicker-icon-btn {
color: var(--text-muted);
@apply absolute inset-y-0 right-2 my-auto inline-flex h-6 w-6 items-center justify-center rounded-md border border-transparent bg-transparent p-0 transition;
}
.datepicker-icon-btn:hover {
color: var(--text-primary);
background-color: var(--ghost-hover);
}
.datepicker-icon-btn:focus-visible {
outline: none;
border-color: rgb(var(--accent-400));
box-shadow: 0 0 0 2px rgb(var(--accent-400) / 0.3);
}
.datepicker-icon-btn:disabled {
color: var(--ghost-disabled-text);
background-color: transparent;
@apply cursor-not-allowed;
}
.datepicker-popup {
border: 1px solid var(--surface-divider);
background-color: var(--surface-bg-strong);
box-shadow: var(--shadow-glow);
color: var(--text-primary);
backdrop-filter: saturate(145%) blur(var(--auth-glass-blur));
-webkit-backdrop-filter: saturate(145%) blur(var(--auth-glass-blur));
will-change: backdrop-filter;
max-width: min(96vw, 440px);
@apply fixed z-[70] flex flex-col gap-3 rounded-xl p-3;
}
.datepicker-popup-top {
transform-origin: bottom center;
}
.datepicker-popup-bottom {
transform-origin: top center;
}
@screen sm {
.datepicker-popup {
@apply flex-row;
}
}
.datepicker-panel {
@apply min-w-0;
}
.datepicker-calendar-nav {
@apply mb-2 flex items-center justify-between gap-2;
}
.datepicker-nav-btn {
border: 1px solid var(--ghost-border);
background-color: var(--ghost-bg);
color: var(--text-secondary);
@apply inline-flex h-8 w-8 items-center justify-center rounded-lg transition;
}
.datepicker-nav-btn:hover {
background-color: var(--ghost-hover);
color: var(--text-primary);
}
.datepicker-heading-controls {
@apply relative flex items-center gap-2;
}
.datepicker-chooser {
@apply relative;
}
.datepicker-chooser-btn {
border: 1px solid var(--field-border);
background-color: var(--field-bg);
color: var(--text-primary);
@apply inline-flex h-8 items-center rounded-lg px-2.5 text-xs font-semibold;
}
.datepicker-chooser-menu {
border: 1px solid var(--surface-divider);
background-color: var(--datepicker-menu-bg);
box-shadow: var(--shadow-glow);
@apply absolute left-0 top-full z-20 mt-1 max-h-48 min-w-[9rem] overflow-y-auto rounded-lg p-1;
}
.datepicker-chooser-option {
color: var(--text-secondary);
@apply block w-full rounded-md px-2 py-1.5 text-left text-xs font-medium transition;
}
.datepicker-chooser-option:hover {
background-color: var(--ghost-hover);
color: var(--text-primary);
}
.datepicker-chooser-option.is-selected {
background-color: rgb(var(--accent-500) / 0.22);
color: rgb(var(--accent-300));
}
.datepicker-weekdays {
@apply mb-1 grid grid-cols-7 gap-1;
}
.datepicker-weekday {
color: var(--text-muted);
@apply text-center text-[0.65rem] font-semibold uppercase tracking-[0.08em];
}
.datepicker-grid {
@apply grid grid-cols-7 gap-1;
}
.datepicker-day {
color: var(--text-secondary);
@apply inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition;
}
.datepicker-day:hover {
background-color: var(--ghost-hover);
color: var(--text-primary);
}
.datepicker-day.is-selected {
background-color: rgb(var(--accent-500));
color: rgb(var(--accent-contrast));
}
.datepicker-day.is-today {
border: 1px solid rgb(var(--accent-400) / 0.65);
}
.datepicker-day.is-outside-month {
color: var(--text-soft);
}
.datepicker-day:disabled {
color: var(--ghost-disabled-text);
background-color: transparent;
@apply cursor-not-allowed opacity-50;
}
.datepicker-time-root {
@apply grid min-w-[180px] grid-cols-2 gap-2;
}
.datepicker-time-column {
@apply flex min-w-0 flex-col gap-1;
}
.datepicker-time-title {
color: var(--text-muted);
@apply text-[0.65rem] font-semibold uppercase tracking-[0.08em];
}
.datepicker-time-list {
border: 1px solid var(--field-border);
background-color: var(--field-bg);
@apply max-h-52 overflow-y-auto rounded-lg p-1;
}
.datepicker-time-option {
color: var(--text-secondary);
@apply block w-full rounded-md px-2 py-1.5 text-left text-xs font-semibold transition;
}
.datepicker-time-option:hover {
background-color: var(--ghost-hover);
color: var(--text-primary);
}
.datepicker-time-option.is-selected {
background-color: rgb(var(--accent-500) / 0.22);
color: rgb(var(--accent-300));
}
.datepicker-time-option:disabled {
color: var(--ghost-disabled-text);
background-color: transparent;
@apply cursor-not-allowed opacity-55;
}

View File

@@ -21,6 +21,12 @@ describe('Button', () => {
expect(screen.getByRole('button', { name: 'Details' })).toHaveClass('btn-secondary');
});
it('uses explicit variant when provided', () => {
render(<Button label="Danger" type="outlined" variant="important" />);
expect(screen.getByRole('button', { name: 'Danger' })).toHaveClass('btn-important');
});
it('renders icon-only button and custom aria label', () => {
render(<Button type="solid" icon={HomeIcon} ariaLabel="Open home" />);

View File

@@ -51,4 +51,12 @@ describe('Chip', () => {
rerender(<Chip tone=" ">Blank</Chip>);
expect(screen.getByText('Blank').getAttribute('style')).toBeNull();
});
it('ignores unresolved direct palettes and unknown shades', () => {
const { rerender } = render(<Chip tone="indigo">Palette token</Chip>);
expect(screen.getByText('Palette token').getAttribute('style')).toBeNull();
rerender(<Chip tone="indigo-999">Unknown shade</Chip>);
expect(screen.getByText('Unknown shade').getAttribute('style')).toBeNull();
});
});

View File

@@ -0,0 +1,428 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { __datePickerTestUtils as utils } from '../../src/components/DatePicker';
type GlobalDescriptor = PropertyDescriptor | undefined;
function setGlobalProperty(name: string, descriptor: PropertyDescriptor): GlobalDescriptor {
const key = name as keyof typeof globalThis;
const original = Object.getOwnPropertyDescriptor(globalThis, key);
Object.defineProperty(globalThis, key, descriptor);
return original;
}
function restoreGlobalProperty(name: string, original: GlobalDescriptor): void {
const key = name as keyof typeof globalThis;
if (original) {
Object.defineProperty(globalThis, key, original);
return;
}
Reflect.deleteProperty(globalThis, key);
}
describe('DatePicker logic helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('formats and clamps numeric values', () => {
expect(utils.pad2(3)).toBe('03');
expect(utils.pad4(12)).toBe('0012');
expect(utils.clampNumber(99, 0, 50)).toBe(50);
expect(utils.clampNumber(-1, 0, 50)).toBe(0);
});
it('validates calendar dates strictly', () => {
expect(utils.createValidatedDate(2026, 2, 29)).toBeNull();
expect(utils.createValidatedDate(2028, 2, 29)).toBeInstanceOf(Date);
});
it('resolves locale with and without navigator', () => {
const originalNavigator = setGlobalProperty('navigator', {
configurable: true,
value: undefined,
});
expect(utils.resolveLocale()).toBe('en-US');
restoreGlobalProperty('navigator', originalNavigator);
const locale = utils.resolveLocale();
expect(typeof locale).toBe('string');
expect(locale.length).toBeGreaterThan(0);
});
it('resolves week start across locale and fallback branches', () => {
const originalIntl = setGlobalProperty('Intl', {
configurable: true,
value: undefined,
});
expect(utils.resolveWeekStart('en-US')).toBe(0);
setGlobalProperty('Intl', {
configurable: true,
value: {
Locale: class MockLocale {
weekInfo = { firstDay: 2 };
},
},
});
expect(utils.resolveWeekStart('de-DE')).toBe(2);
setGlobalProperty('Intl', {
configurable: true,
value: {
Locale: class MockLocale {
weekInfo = { firstDay: 7 };
},
},
});
expect(utils.resolveWeekStart('en-US')).toBe(0);
setGlobalProperty('Intl', {
configurable: true,
value: {
Locale: class MockLocale {
weekInfo = { firstDay: 0 };
},
},
});
expect(utils.resolveWeekStart('en-US')).toBe(0);
setGlobalProperty('Intl', {
configurable: true,
value: {
Locale: class MockLocale {
weekInfo = { firstDay: 'x' as unknown as number };
},
},
});
expect(utils.resolveWeekStart('en-US')).toBe(0);
setGlobalProperty('Intl', {
configurable: true,
value: {
Locale: class MockLocale {
constructor() {
throw new Error('boom');
}
},
},
});
expect(utils.resolveWeekStart('en-US')).toBe(0);
restoreGlobalProperty('Intl', originalIntl);
});
it('builds and validates format configuration', () => {
expect(utils.buildFormatConfigOrNull('date-time', '')).toBeNull();
expect(utils.buildFormatConfigOrNull('date-time', 'yyyy/mm/dd HH')).toBeNull();
const config = utils.buildFormatConfigOrNull('date-time', 'dd/mm/yyyy HH:mm');
expect(config).not.toBeNull();
expect(config?.segments).toHaveLength(5);
expect(config?.totalLength).toBe(16);
});
it('falls back to default format when requested format is invalid', () => {
const config = utils.buildFormatConfig('date-time', 'yyyy/mm/dd');
expect(config.format).toBe('yyyy/mm/dd HH:mm');
});
it('parses valid values and rejects invalid input', () => {
const dateTimeConfig = utils.buildFormatConfig('date-time', 'dd/mm/yyyy HH:mm');
expect(utils.parsePickerValueWithFormat('22/02/2026 14:30', dateTimeConfig)).toEqual({
date: utils.createDateAtLocalMidnight(2026, 1, 22),
hour: 14,
minute: 30,
});
expect(utils.parsePickerValueWithFormat('2/2/2026 14:30', dateTimeConfig)).toBeNull();
expect(utils.parsePickerValueWithFormat('22-02-2026 14:30', dateTimeConfig)).toBeNull();
expect(utils.parsePickerValueWithFormat('aa/02/2026 14:30', dateTimeConfig)).toBeNull();
expect(utils.parsePickerValueWithFormat('31/02/2026 14:30', dateTimeConfig)).toBeNull();
expect(utils.parsePickerValueWithFormat('22/02/2026 24:30', dateTimeConfig)).toBeNull();
const timeConfig = utils.buildFormatConfig('time', 'HH:mm');
expect(utils.parsePickerValueWithFormat('09:00', timeConfig)).toEqual({
date: utils.startOfDay(new Date()),
hour: 9,
minute: 0,
});
expect(utils.parsePickerValueWithFormat('09:77', timeConfig)).toBeNull();
});
it('formats picker values with the chosen configuration', () => {
const config = utils.buildFormatConfig('date-time', 'dd/mm/yyyy HH:mm');
const text = utils.formatPickerValueWithFormat(
{
date: utils.createDateAtLocalMidnight(2027, 2, 11),
hour: 6,
minute: 5,
},
config,
);
expect(text).toBe('11/03/2027 06:05');
});
it('compares values by picker type', () => {
const date = utils.comparePickerValue(
{
date: utils.createDateAtLocalMidnight(2026, 1, 1),
hour: 23,
minute: 30,
},
{
date: utils.createDateAtLocalMidnight(2026, 1, 2),
hour: 1,
minute: 0,
},
'date',
);
expect(date).toBeLessThan(0);
const time = utils.comparePickerValue(
{
date: utils.createDateAtLocalMidnight(2026, 1, 1),
hour: 9,
minute: 0,
},
{
date: utils.createDateAtLocalMidnight(2026, 1, 1),
hour: 8,
minute: 59,
},
'time',
);
expect(time).toBeGreaterThan(0);
const dateTime = utils.comparePickerValue(
{
date: utils.createDateAtLocalMidnight(2026, 1, 1),
hour: 1,
minute: 0,
},
{
date: utils.createDateAtLocalMidnight(2026, 1, 1),
hour: 1,
minute: 1,
},
'date-time',
);
expect(dateTime).toBeLessThan(0);
});
it('normalizes and enforces picker ranges', () => {
const min = {
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 9,
minute: 0,
};
const max = {
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 10,
minute: 0,
};
const normalized = utils.normalizeRange(max, min, 'date-time');
expect(normalized.minValue).toEqual(min);
expect(normalized.maxValue).toEqual(max);
const below = utils.clampPickerToRange(
{
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 8,
minute: 0,
},
min,
max,
'date-time',
);
expect(below).toEqual(min);
const above = utils.clampPickerToRange(
{
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 10,
minute: 30,
},
min,
max,
'date-time',
);
expect(above).toEqual(max);
expect(utils.isWithinRange(min, min, max, 'date-time')).toBe(true);
expect(
utils.isWithinRange(
{
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 7,
minute: 59,
},
min,
max,
'date-time',
),
).toBe(false);
expect(
utils.isWithinRange(
{
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 10,
minute: 1,
},
min,
max,
'date-time',
),
).toBe(false);
});
it('applies segment edits across year, month, day, hour and minute', () => {
const base = {
date: utils.createDateAtLocalMidnight(2026, 1, 28),
hour: 14,
minute: 30,
};
expect(utils.applySegmentDigits(base, 'year', '0001').date.getFullYear()).toBe(1);
expect(utils.applySegmentDigits(base, 'month', '13').date.getMonth()).toBe(11);
expect(utils.applySegmentDigits(base, 'day', '99').date.getDate()).toBe(28);
expect(utils.applySegmentDigits(base, 'hour', '88').hour).toBe(23);
expect(utils.applySegmentDigits(base, 'minute', '99').minute).toBe(59);
const unchanged = utils.applySegmentDigits(base, 'day', 'not-a-number');
expect(unchanged).toEqual(base);
});
it('resolves segment index from caret position', () => {
const config = utils.buildFormatConfig('date-time', 'dd/mm/yyyy HH:mm');
expect(utils.findSegmentIndexByCaret([], 3)).toBe(0);
expect(utils.findSegmentIndexByCaret(config.segments, null)).toBe(0);
expect(utils.findSegmentIndexByCaret(config.segments, 0)).toBe(0);
expect(utils.findSegmentIndexByCaret(config.segments, 6)).toBe(2);
expect(utils.findSegmentIndexByCaret(config.segments, 99)).toBe(
config.segments[config.segments.length - 1].segmentIndex,
);
expect(utils.findSegmentIndexByCaret(config.segments, 2)).toBe(0);
const nonStandardSegments = [
{
type: 'segment',
kind: 'day',
token: 'dd',
length: 2,
start: 5,
end: 7,
segmentIndex: 0,
},
] as unknown as Parameters<typeof utils.findSegmentIndexByCaret>[0];
expect(utils.findSegmentIndexByCaret(nonStandardSegments, 1)).toBe(0);
});
it('checks date and hour selectability inside constrained ranges', () => {
const min = {
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 9,
minute: 30,
};
const max = {
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 10,
minute: 15,
};
expect(
utils.isDateSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 9),
'date-time',
min,
max,
),
).toBe(false);
expect(
utils.isDateSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 10),
'date-time',
min,
max,
),
).toBe(true);
expect(
utils.isDateSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 11),
'date-time',
min,
max,
),
).toBe(false);
expect(
utils.isDateSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 9),
'date',
{
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 0,
minute: 0,
},
{
date: utils.createDateAtLocalMidnight(2026, 2, 10),
hour: 0,
minute: 0,
},
),
).toBe(false);
expect(
utils.isHourSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 10),
8,
'date-time',
min,
max,
),
).toBe(false);
expect(
utils.isHourSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 10),
9,
'date-time',
min,
max,
),
).toBe(true);
expect(
utils.isHourSelectableForRange(
utils.createDateAtLocalMidnight(2026, 2, 10),
11,
'date-time',
min,
max,
),
).toBe(false);
});
it('builds month grids and joins class names', () => {
const month = utils.createDateAtLocalMidnight(2026, 2, 10);
const grid = utils.buildMonthGrid(month, 1);
expect(grid).toHaveLength(42);
expect(grid[0]).toBeInstanceOf(Date);
expect(grid[41]).toBeInstanceOf(Date);
expect(utils.joinClassNames('a', false, 'b', undefined, '', null, 'c')).toBe('a b c');
});
it('assigns refs for callback and object refs', () => {
const callback = vi.fn();
const objectRef = { current: null as HTMLInputElement | null };
const node = document.createElement('input');
utils.assignRef(callback, node);
utils.assignRef(objectRef, node);
utils.assignRef(undefined, node);
expect(callback).toHaveBeenCalledWith(node);
expect(objectRef.current).toBe(node);
});
});

View File

@@ -1,41 +1,386 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { createRef, type FocusEvent as ReactFocusEvent, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { DatePicker } from '../../src/components/DatePicker';
type ControlledProps = {
type: 'date' | 'time' | 'date-time';
initialValue: string;
format?: string;
min?: string;
max?: string;
onValueChange?: (value: string) => void;
onBlur?: (event: ReactFocusEvent<HTMLInputElement>) => void;
disabled?: boolean;
};
function ControlledDatePicker({
type,
initialValue,
format,
min,
max,
onValueChange,
onBlur,
disabled = false,
}: Readonly<ControlledProps>) {
const [value, setValue] = useState(initialValue);
return (
<DatePicker
label="Schedule"
type={type}
value={value}
format={format}
min={min}
max={max}
disabled={disabled}
onBlur={onBlur}
onChange={(event) => {
setValue(event.target.value);
onValueChange?.(event.target.value);
}}
/>
);
}
function pickCurrentMonthDay(label: string): void {
const dayButtons = Array.from(document.querySelectorAll('.datepicker-day')) as HTMLButtonElement[];
const targetButton = dayButtons.find(
(button) => button.textContent === label && !button.classList.contains('is-outside-month'),
);
expect(targetButton).toBeDefined();
fireEvent.click(targetButton as HTMLButtonElement);
}
function getCurrentMonthDayButton(label: string): HTMLButtonElement {
const dayButtons = Array.from(document.querySelectorAll('.datepicker-day')) as HTMLButtonElement[];
const targetButton = dayButtons.find(
(button) => button.textContent === label && !button.classList.contains('is-outside-month'),
);
if (!targetButton) {
throw new Error(`Could not find day button for ${label}`);
}
return targetButton;
}
function createPasteEvent(text: string): Event {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
getData: () => text,
},
});
return event;
}
function createRect(left: number, top: number, width: number, height: number): DOMRect {
return {
x: left,
y: top,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
} as DOMRect;
}
describe('DatePicker', () => {
it('supports datetime-local type and change callback', () => {
const onChange = vi.fn();
render(<DatePicker label="Schedule" type="datetime-local" value="" onChange={onChange} />);
it('opens popup from icon button and closes with Escape', () => {
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
expect(input.type).toBe('datetime-local');
expect(input.type).toBe('text');
fireEvent.change(input, { target: { value: '2031-05-20T14:30' } });
expect(onChange).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
expect(
screen.getByRole('dialog', { name: 'Date and time picker popup' }),
).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
fireEvent.click(screen.getByRole('button', { name: 'Close date picker' }));
expect(
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
).not.toBeInTheDocument();
});
it('supports date type and disabled state', () => {
it('supports segment-by-segment editing with custom format and auto-advance', async () => {
const onValueChange = vi.fn();
render(
<DatePicker
label="Publish date"
type="date"
value="2031-05-20"
onChange={() => {}}
disabled
<ControlledDatePicker
type="date-time"
format="dd/mm/yyyy HH:mm"
initialValue="22/02/2026 14:30"
onValueChange={onValueChange}
/>,
);
const input = screen.getByLabelText('Publish date') as HTMLInputElement;
expect(input.type).toBe('date');
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
fireEvent.focus(input);
await waitFor(() => {
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(2);
});
fireEvent.keyDown(input, { key: '1' });
await waitFor(() => {
expect(input.value).toBe('01/02/2026 14:30');
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(2);
});
fireEvent.keyDown(input, { key: '1' });
await waitFor(() => {
expect(input.value).toBe('11/02/2026 14:30');
expect(input.selectionStart).toBe(3);
expect(input.selectionEnd).toBe(5);
});
fireEvent.keyDown(input, { key: '0' });
fireEvent.keyDown(input, { key: '3' });
await waitFor(() => {
expect(input.value).toBe('11/03/2026 14:30');
expect(input.selectionStart).toBe(6);
expect(input.selectionEnd).toBe(10);
});
fireEvent.keyDown(input, { key: '2' });
fireEvent.keyDown(input, { key: '0' });
fireEvent.keyDown(input, { key: '2' });
fireEvent.keyDown(input, { key: '7' });
await waitFor(() => {
expect(input.value).toBe('11/03/2027 14:30');
expect(input.selectionStart).toBe(11);
expect(input.selectionEnd).toBe(13);
});
fireEvent.keyDown(input, { key: '1' });
fireEvent.keyDown(input, { key: '6' });
await waitFor(() => {
expect(input.value).toBe('11/03/2027 16:30');
expect(input.selectionStart).toBe(14);
expect(input.selectionEnd).toBe(16);
});
fireEvent.keyDown(input, { key: '4' });
fireEvent.keyDown(input, { key: '5' });
await waitFor(() => {
expect(input.value).toBe('11/03/2027 16:45');
});
expect(onValueChange).toHaveBeenCalled();
});
it('preserves year 0000 while editing and does not coerce to 19xx', async () => {
render(
<ControlledDatePicker
type="date-time"
format="dd/mm/yyyy HH:mm"
initialValue="22/02/2026 14:30"
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
fireEvent.focus(input);
await waitFor(() => {
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(2);
});
fireEvent.keyDown(input, { key: '/' });
await waitFor(() => {
expect(input.selectionStart).toBe(3);
expect(input.selectionEnd).toBe(5);
});
fireEvent.keyDown(input, { key: '/' });
await waitFor(() => {
expect(input.selectionStart).toBe(6);
expect(input.selectionEnd).toBe(10);
});
fireEvent.keyDown(input, { key: '0' });
await waitFor(() => {
expect(input.value).toBe('22/02/0000 14:30');
expect(input.selectionStart).toBe(6);
expect(input.selectionEnd).toBe(10);
});
fireEvent.keyDown(input, { key: '0' });
fireEvent.keyDown(input, { key: '0' });
fireEvent.keyDown(input, { key: '0' });
await waitFor(() => {
expect(input.value).toBe('22/02/0000 14:30');
expect(input.selectionStart).toBe(11);
expect(input.selectionEnd).toBe(13);
});
});
it('uses separator keys to move between editable segments', async () => {
render(
<ControlledDatePicker
type="date-time"
format="dd/mm/yyyy HH:mm"
initialValue="22/02/2026 14:30"
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
fireEvent.focus(input);
await waitFor(() => {
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(2);
});
fireEvent.keyDown(input, { key: '/' });
await waitFor(() => {
expect(input.selectionStart).toBe(3);
expect(input.selectionEnd).toBe(5);
});
fireEvent.keyDown(input, { key: '/' });
await waitFor(() => {
expect(input.selectionStart).toBe(6);
expect(input.selectionEnd).toBe(10);
});
fireEvent.keyDown(input, { key: ' ' });
await waitFor(() => {
expect(input.selectionStart).toBe(11);
expect(input.selectionEnd).toBe(13);
});
fireEvent.keyDown(input, { key: ':' });
await waitFor(() => {
expect(input.selectionStart).toBe(14);
expect(input.selectionEnd).toBe(16);
});
});
it('applies min/max constraints to calendar and time options', () => {
render(
<ControlledDatePicker
type="date-time"
initialValue="2026/03/10 10:00"
min="2026/03/10 09:30"
max="2026/03/10 10:15"
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
expect(getCurrentMonthDayButton('9')).toBeDisabled();
expect(getCurrentMonthDayButton('10')).toBeEnabled();
expect(getCurrentMonthDayButton('11')).toBeDisabled();
const hoursList = screen.getByRole('listbox', { name: 'Hours' });
expect(within(hoursList).getByRole('button', { name: '08' })).toBeDisabled();
expect(within(hoursList).getByRole('button', { name: '09' })).toBeEnabled();
expect(within(hoursList).getByRole('button', { name: '10' })).toBeEnabled();
expect(within(hoursList).getByRole('button', { name: '11' })).toBeDisabled();
const minutesList = screen.getByRole('listbox', { name: 'Minutes' });
expect(within(minutesList).getByRole('button', { name: '15' })).toBeEnabled();
expect(within(minutesList).getByRole('button', { name: '20' })).toBeDisabled();
});
it('renders date mode calendar only and auto-closes after day selection', () => {
const onValueChange = vi.fn();
render(
<ControlledDatePicker
type="date"
initialValue="2031/05/20"
onValueChange={onValueChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
expect(screen.getByRole('dialog', { name: 'Date picker popup' })).toBeInTheDocument();
expect(screen.queryByText('Hours')).not.toBeInTheDocument();
expect(screen.queryByText('Minutes')).not.toBeInTheDocument();
pickCurrentMonthDay('15');
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
expect(input.value).toMatch(/^\d{4}\/\d{2}\/15$/);
expect(onValueChange).toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: 'Date picker popup' })).not.toBeInTheDocument();
});
it('commits calendar date changes in date-time mode without closing the popup', () => {
const onValueChange = vi.fn();
render(
<ControlledDatePicker
type="date-time"
initialValue="2031/05/20 14:30"
onValueChange={onValueChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
pickCurrentMonthDay('15');
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
expect(input.value).toMatch(/^\d{4}\/\d{2}\/15 14:30$/);
expect(onValueChange).toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
});
it('renders time mode selectors only and keeps popup open after selection', () => {
const onValueChange = vi.fn();
render(<ControlledDatePicker type="time" initialValue="09:00" onValueChange={onValueChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
expect(screen.getByRole('dialog', { name: 'Time picker popup' })).toBeInTheDocument();
expect(screen.queryByRole('grid')).not.toBeInTheDocument();
const hoursList = screen.getByRole('listbox', { name: 'Hours' });
const minutesList = screen.getByRole('listbox', { name: 'Minutes' });
fireEvent.click(within(hoursList).getByRole('button', { name: '13' }));
fireEvent.click(within(minutesList).getByRole('button', { name: '45' }));
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
expect(input.value).toBe('13:45');
expect(onValueChange).toHaveBeenCalledTimes(2);
expect(screen.getByRole('dialog', { name: 'Time picker popup' })).toBeInTheDocument();
});
it('blocks popup interactions while disabled', () => {
render(<ControlledDatePicker type="date" initialValue="2031/05/20" disabled />);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
const iconButton = screen.getByRole('button', { name: 'Open date picker' });
expect(input).toBeDisabled();
expect(iconButton).toBeDisabled();
fireEvent.click(iconButton);
expect(screen.queryByRole('dialog', { name: 'Date picker popup' })).not.toBeInTheDocument();
});
it('renders right icon and error message', () => {
const { container } = render(
<DatePicker
label="Schedule"
type="datetime-local"
value=""
type="date-time"
value="2031/05/20 14:30"
onChange={() => {}}
rightIcon={<span data-testid="right-icon">R</span>}
error="Invalid date"
@@ -51,6 +396,37 @@ describe('DatePicker', () => {
expect(screen.getByText('Invalid date')).toBeInTheDocument();
});
it('forwards inputRef for callback and object refs', () => {
const callbackRef = vi.fn();
const objectRef = createRef<HTMLInputElement>();
const { rerender } = render(
<DatePicker
label="Schedule"
type="date-time"
value="2031/05/20 14:30"
onChange={() => {}}
inputRef={callbackRef}
/>,
);
expect(callbackRef).toHaveBeenCalled();
const callbackNode = callbackRef.mock.calls.find(([node]) => node instanceof HTMLInputElement)?.[0];
expect(callbackNode).toBeInstanceOf(HTMLInputElement);
rerender(
<DatePicker
label="Schedule"
type="date-time"
value="2031/05/20 14:30"
onChange={() => {}}
inputRef={objectRef}
/>,
);
expect(objectRef.current).toBeInstanceOf(HTMLInputElement);
});
it('supports inline layout', () => {
const { container } = render(
<DatePicker label="Start time" type="time" value="09:00" onChange={() => {}} layout="inline" />,
@@ -59,4 +435,306 @@ describe('DatePicker', () => {
expect(container.querySelector('label')).toHaveClass('inline-flex');
expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
});
it('handles outside vs inside pointer interactions for popup close behavior', () => {
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
const dialog = screen.getByRole('dialog', { name: 'Date and time picker popup' });
fireEvent.mouseDown(dialog);
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
fireEvent.mouseDown(document.body);
expect(
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
).not.toBeInTheDocument();
});
it('supports month/year chooser interactions and month navigation buttons', async () => {
render(<DatePicker label="Schedule" type="date" value="2031/05/20" onChange={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
const chooserButtons = document.querySelectorAll('.datepicker-chooser-btn');
const monthChooserButton = chooserButtons[0] as HTMLButtonElement;
const yearChooserButton = chooserButtons[1] as HTMLButtonElement;
const initialMonthText = monthChooserButton.textContent;
fireEvent.click(monthChooserButton);
const monthList = screen.getByRole('listbox', { name: 'Choose month' });
const monthOptions = within(monthList).getAllByRole('button');
const nextMonth = monthOptions.find((option) => option.textContent !== initialMonthText) ?? monthOptions[0];
fireEvent.click(nextMonth);
await waitFor(() => {
expect(screen.queryByRole('listbox', { name: 'Choose month' })).not.toBeInTheDocument();
});
expect((document.querySelectorAll('.datepicker-chooser-btn')[0] as HTMLButtonElement).textContent).toBe(
nextMonth.textContent,
);
const initialYearText = yearChooserButton.textContent;
fireEvent.click(yearChooserButton);
const yearList = screen.getByRole('listbox', { name: 'Choose year' });
const yearOptions = within(yearList).getAllByRole('button');
const nextYear = yearOptions.find((option) => option.textContent !== initialYearText) ?? yearOptions[0];
fireEvent.click(nextYear);
await waitFor(() => {
expect(screen.queryByRole('listbox', { name: 'Choose year' })).not.toBeInTheDocument();
});
expect((document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement).textContent).toBe(
nextYear.textContent,
);
});
it('guards month navigation at absolute calendar boundaries', () => {
const first = render(
<DatePicker label="Schedule" type="date" value="0000/01/15" onChange={() => {}} />,
);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
const lowerYearButton = document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement;
expect(lowerYearButton.textContent).toBe('0');
first.unmount();
render(<DatePicker label="Schedule" type="date" value="9999/12/15" onChange={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
const upperYearButton = document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement;
expect(upperYearButton.textContent).toBe('9999');
});
it('supports keyboard navigation commands and segment reset shortcuts', async () => {
const onBlur = vi.fn();
render(
<ControlledDatePicker
type="date-time"
format="dd/mm/yyyy HH:mm"
initialValue="22/02/2026 14:30"
onBlur={onBlur}
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
fireEvent.focus(input);
await waitFor(() => {
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(2);
});
fireEvent.keyDown(input, { key: '1' });
fireEvent.keyDown(input, { key: 'ArrowRight' });
await waitFor(() => {
expect(input.value.startsWith('01/')).toBe(true);
expect(input.selectionStart).toBe(3);
});
fireEvent.mouseUp(input);
fireEvent.click(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
fireEvent.keyDown(input, { key: 'ArrowRight' });
await waitFor(() => expect(input.selectionStart).toBe(3));
fireEvent.keyDown(input, { key: 'ArrowLeft' });
await waitFor(() => expect((input.selectionStart ?? 0) <= 3).toBe(true));
fireEvent.keyDown(input, { key: 'Tab' });
await waitFor(() => expect((input.selectionStart ?? 0) >= 0).toBe(true));
fireEvent.keyDown(input, { key: 'Tab', shiftKey: true });
await waitFor(() => expect((input.selectionStart ?? 0) >= 0).toBe(true));
fireEvent.keyDown(input, { key: 'Backspace' });
await waitFor(() => {
expect(input.value.startsWith('01/')).toBe(true);
});
fireEvent.keyDown(input, { key: '/' });
await waitFor(() => expect(input.selectionStart).toBe(3));
fireEvent.keyDown(input, { key: 'Delete' });
await waitFor(() => {
expect(input.value.slice(3, 5)).toBe('01');
});
fireEvent.keyDown(input, { key: '/' });
await waitFor(() => expect(input.selectionStart).toBe(6));
fireEvent.keyDown(input, { key: 'Delete' });
fireEvent.keyDown(input, { key: ' ' });
fireEvent.keyDown(input, { key: 'Delete' });
await waitFor(() => {
expect(/\s00:|:00$/.test(input.value)).toBe(true);
});
fireEvent.keyDown(input, { key: 'Enter' });
fireEvent.keyDown(input, { key: 'x' });
fireEvent.blur(input);
expect(onBlur).toHaveBeenCalledTimes(1);
});
it('handles paste validation and value commits', async () => {
render(
<ControlledDatePicker
type="date-time"
format="dd/mm/yyyy HH:mm"
initialValue="22/02/2026 14:30"
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
await act(async () => {
input.dispatchEvent(createPasteEvent('invalid value'));
});
expect(input.value).toBe('22/02/2026 14:30');
await act(async () => {
input.dispatchEvent(createPasteEvent('11/03/2027 16:45'));
});
expect(input.value).toBe('11/03/2027 16:45');
});
it('returns early from interaction handlers when disabled', () => {
render(
<DatePicker
label="Schedule"
type="date-time"
format="dd/mm/yyyy HH:mm"
value="22/02/2026 14:30"
disabled
onChange={() => {}}
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
fireEvent.focus(input);
fireEvent.mouseUp(input);
fireEvent.click(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
const disabledPaste = createPasteEvent('11/03/2027 16:45');
input.dispatchEvent(disabledPaste);
expect(disabledPaste.defaultPrevented).toBe(false);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('falls back to direct assignment and synthetic onChange when native dispatch is stubbed', async () => {
const onChange = vi.fn();
const originalGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
render(
<DatePicker
label="Schedule"
type="date-time"
format="dd/mm/yyyy HH:mm"
value="22/02/2026 14:30"
onChange={onChange}
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
const nativeDispatchEvent = input.dispatchEvent.bind(input);
vi.spyOn(input, 'dispatchEvent').mockImplementation((event: Event) => {
if (event.type === 'change') {
return true;
}
return nativeDispatchEvent(event);
});
vi.spyOn(Object, 'getOwnPropertyDescriptor').mockImplementation((target, property) => {
if (target === HTMLInputElement.prototype && property === 'value') {
return {
configurable: true,
enumerable: true,
get: originalGetOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.get,
};
}
return originalGetOwnPropertyDescriptor(target, property);
});
fireEvent.focus(input);
input.setSelectionRange(0, 2);
fireEvent.keyDown(input, { key: '1' });
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
});
it('safely handles pending segment selection during unmount', () => {
vi.useFakeTimers();
const { unmount } = render(
<DatePicker
label="Schedule"
type="date-time"
format="dd/mm/yyyy HH:mm"
value="22/02/2026 14:30"
onChange={() => {}}
/>,
);
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
fireEvent.focus(input);
unmount();
expect(() => {
vi.runAllTimers();
}).not.toThrow();
});
it('recalculates popup position on window resize and scroll', async () => {
const originalInnerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
const originalInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight');
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function mockRect(this: HTMLElement) {
if (this.classList.contains('datepicker-popup')) {
return createRect(0, 0, 300, 200);
}
if (this.classList.contains('relative') && this.querySelector('input')) {
return createRect(500, 80, 120, 40);
}
return createRect(0, 0, 100, 30);
});
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 480 });
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
const dialog = screen.getByRole('dialog', { name: 'Date and time picker popup' });
expect(dialog).toBeInTheDocument();
await waitFor(() => {
expect(dialog.style.left).toBe('292px');
});
fireEvent(window, new Event('resize'));
fireEvent(window, new Event('scroll'));
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
expect(dialog.style.left).toBe('292px');
rectSpy.mockRestore();
if (originalInnerWidth) {
Object.defineProperty(window, 'innerWidth', originalInnerWidth);
}
if (originalInnerHeight) {
Object.defineProperty(window, 'innerHeight', originalInnerHeight);
}
});
});

View File

@@ -52,4 +52,9 @@ describe('Dropdown', () => {
expect(select).toHaveClass('custom-select');
expect(screen.getByText('Role').closest('label')).toHaveClass('custom-wrapper');
});
it('supports rendering without a label', () => {
const { container } = render(<Dropdown value="USER" choices={choices} />);
expect(container.querySelector('label > span')).toBeNull();
});
});

View File

@@ -89,4 +89,9 @@ describe('InputField', () => {
expect(container.querySelector('label')).toHaveClass('inline-flex');
expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
});
it('supports rendering without a label', () => {
const { container } = render(<InputField type="text" value="" onChange={() => {}} />);
expect(container.querySelector('label > span')).toBeNull();
});
});

View File

@@ -9,7 +9,10 @@ vi.mock('@mdxeditor/editor', () => import('./mocks/mdxeditor'));
afterEach(() => {
cleanup();
localStorage.clear();
const storage = globalThis.window?.localStorage ?? globalThis.localStorage;
if (typeof storage?.clear === 'function') {
storage.clear();
}
vi.restoreAllMocks();
vi.useRealTimers();
});

View File

@@ -34,15 +34,17 @@ export default defineConfig({
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.story.{ts,tsx}',
'src/**/*.stories.{ts,tsx}',
'src/index.ts',
'src/styles/**',
'src/components/types.ts',
'src/types/**',
],
thresholds: {
lines: 95,
functions: 95,
branches: 90,
lines: 80,
functions: 75,
branches: 70,
},
},
},

View File

@@ -1890,9 +1890,9 @@
integrity sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==
"@storybook/addon-a11y@^10.2.10":
version "10.2.10"
resolved "https://nexus.beatrice.wtf/repository/npm-group/@storybook/addon-a11y/-/addon-a11y-10.2.10.tgz#8c80cdcce50eecc52aa7b0fec69dd2bcb92eef98"
integrity sha512-1S9pDXgvbHhBStGarCvfJ3/rfcaiAcQHRhuM3Nk4WGSIYtC1LCSRuzYdDYU0aNRpdCbCrUA7kUCbqvIE3tH+3Q==
version "10.2.12"
resolved "https://nexus.beatrice.wtf/repository/npm-group/@storybook/addon-a11y/-/addon-a11y-10.2.12.tgz#d316b83772ead018fa6fa1bf548f48c61b6a0d88"
integrity sha512-UFJU9cOqaOLcb6WnO/y0urB6EUqPCxtNvIsiJ1MviCm9IqV+b3Y84NFxWYvYSBuYzqt89DKdh19edP6aVaLe6w==
dependencies:
"@storybook/global" "^5.0.0"
axe-core "^4.2.0"
@@ -2118,6 +2118,13 @@
resolved "https://nexus.beatrice.wtf/repository/npm-group/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78"
integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
"@types/node@^25.3.0":
version "25.3.0"
resolved "https://nexus.beatrice.wtf/repository/npm-group/@types/node/-/node-25.3.0.tgz#749b1bd4058e51b72e22bd41e9eab6ebd0180470"
integrity sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==
dependencies:
undici-types "~7.18.0"
"@types/react-dom@^19.0.0":
version "19.2.3"
resolved "https://nexus.beatrice.wtf/repository/npm-group/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c"
@@ -4538,16 +4545,16 @@ react-remove-scroll@^2.6.3:
use-sidecar "^1.1.3"
react-router-dom@^7.0.0:
version "7.13.1"
resolved "https://nexus.beatrice.wtf/repository/npm-group/react-router-dom/-/react-router-dom-7.13.1.tgz#74c045acc333ca94612b889cd1b1e1ee9534dead"
integrity sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==
version "7.13.0"
resolved "https://nexus.beatrice.wtf/repository/npm-group/react-router-dom/-/react-router-dom-7.13.0.tgz#8b5f7204fadca680f0e94f207c163f0dcd1cfdf5"
integrity sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==
dependencies:
react-router "7.13.1"
react-router "7.13.0"
react-router@7.13.1:
version "7.13.1"
resolved "https://nexus.beatrice.wtf/repository/npm-group/react-router/-/react-router-7.13.1.tgz#5e2b3ebafd6c78d9775e135474bf5060645077f7"
integrity sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==
react-router@7.13.0:
version "7.13.0"
resolved "https://nexus.beatrice.wtf/repository/npm-group/react-router/-/react-router-7.13.0.tgz#de9484aee764f4f65b93275836ff5944d7f5bd3b"
integrity sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
@@ -5001,6 +5008,11 @@ typescript@^5.6.2:
resolved "https://nexus.beatrice.wtf/repository/npm-group/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
undici-types@~7.18.0:
version "7.18.2"
resolved "https://nexus.beatrice.wtf/repository/npm-group/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9"
integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==
undici@^7.21.0:
version "7.22.0"
resolved "https://nexus.beatrice.wtf/repository/npm-group/undici/-/undici-7.22.0.tgz#7a82590a5908e504a47d85c60b0f89ca14240e60"