feat(primitives): media-editor components, category reorg, perf + type cleanup
Reorganize components into category folders (forms/canvas/overlays/etc.); add the media-editor headless family (timeline, curve-editor, waveform, crop, color picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag state, gesture-leak teardown, shallowRef color state, rect caching) and replace source `any` with proper types.
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
import type { CalendarMonth } from '../utils';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import {
|
||||
CalendarCell,
|
||||
CalendarCellTrigger,
|
||||
CalendarGrid,
|
||||
CalendarGridBody,
|
||||
CalendarGridHead,
|
||||
CalendarGridRow,
|
||||
CalendarHeadCell,
|
||||
CalendarRoot,
|
||||
} from '../index';
|
||||
import { findFirstFocusableDate, getLocaleWeekStartsOn, toIsoDate } from '../utils';
|
||||
|
||||
function mountCalendar(
|
||||
props: Record<string, unknown> = {},
|
||||
options: Record<string, unknown> = {},
|
||||
) {
|
||||
return mount(defineComponent({
|
||||
setup: () => () => h(CalendarRoot, props, {
|
||||
default: ({ grid, weekDays }: { grid: CalendarMonth[]; weekDays: string[] }) =>
|
||||
grid.map(month =>
|
||||
h(CalendarGrid, { key: month.value.toString(), month: month.value }, {
|
||||
default: () => [
|
||||
h(CalendarGridHead, null, {
|
||||
default: () => h(CalendarGridRow, null, {
|
||||
default: () => weekDays.map((wd, i) => h(CalendarHeadCell, { key: i }, () => wd)),
|
||||
}),
|
||||
}),
|
||||
h(CalendarGridBody, null, {
|
||||
default: () => month.weeks.map((week, w) =>
|
||||
h(CalendarGridRow, { key: w }, {
|
||||
default: () => week.map(day =>
|
||||
h(CalendarCell, { key: day.toString(), date: day }, {
|
||||
default: () => h(CalendarCellTrigger, { day, month: month.value }),
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
],
|
||||
})),
|
||||
}),
|
||||
}), options);
|
||||
}
|
||||
|
||||
function press(el: Element, key: string) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
describe('Calendar — accessibility skeleton', () => {
|
||||
it('renders an SR-only role=heading mirroring the calendar label', () => {
|
||||
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1) });
|
||||
const srHeading = w.find('[data-primitives-calendar-sr-heading] [role="heading"]');
|
||||
expect(srHeading.exists()).toBe(true);
|
||||
expect(srHeading.attributes('aria-level')).toBe('2');
|
||||
expect(srHeading.text()).toContain('2026');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('marks the grid head as aria-hidden', () => {
|
||||
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1) });
|
||||
const head = w.find('[data-primitives-calendar-grid-head]');
|
||||
expect(head.attributes('aria-hidden')).toBe('true');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Calendar — single mode deselect', () => {
|
||||
it('re-clicking the selected date clears the model (deselect-on-reclick)', async () => {
|
||||
const selected = new Date(2026, 5, 15);
|
||||
const w = mountCalendar({ defaultValue: selected, defaultPlaceholder: selected });
|
||||
const cell = w.find('[data-value="2026-06-15"]:not([data-outside-view])');
|
||||
await cell.trigger('click');
|
||||
await nextTick();
|
||||
expect(w.find('[data-value="2026-06-15"][data-selected]').exists()).toBe(false);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('preventDeselect keeps the selected date on re-click', async () => {
|
||||
const selected = new Date(2026, 5, 15);
|
||||
const w = mountCalendar({ defaultValue: selected, defaultPlaceholder: selected, preventDeselect: true });
|
||||
const cell = w.find('[data-value="2026-06-15"]:not([data-outside-view])');
|
||||
await cell.trigger('click');
|
||||
await nextTick();
|
||||
expect(w.find('[data-value="2026-06-15"]:not([data-outside-view])').attributes('data-selected')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Calendar — multiple mode', () => {
|
||||
it('toggles membership: click adds, re-click removes', async () => {
|
||||
const w = mountCalendar({ multiple: true, defaultPlaceholder: new Date(2026, 5, 1) });
|
||||
const a = () => w.find('[data-value="2026-06-10"]:not([data-outside-view])');
|
||||
const b = () => w.find('[data-value="2026-06-20"]:not([data-outside-view])');
|
||||
|
||||
await a().trigger('click');
|
||||
await b().trigger('click');
|
||||
await nextTick();
|
||||
expect(a().attributes('data-selected')).toBe('');
|
||||
expect(b().attributes('data-selected')).toBe('');
|
||||
|
||||
await a().trigger('click');
|
||||
await nextTick();
|
||||
expect(a().attributes('data-selected')).toBeUndefined();
|
||||
expect(b().attributes('data-selected')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('accepts an array defaultValue and marks each as selected', async () => {
|
||||
const w = mountCalendar({
|
||||
multiple: true,
|
||||
defaultValue: [new Date(2026, 5, 5), new Date(2026, 5, 12)],
|
||||
defaultPlaceholder: new Date(2026, 5, 1),
|
||||
});
|
||||
await nextTick();
|
||||
expect(w.find('[data-value="2026-06-05"]:not([data-outside-view])').attributes('data-selected')).toBe('');
|
||||
expect(w.find('[data-value="2026-06-12"]:not([data-outside-view])').attributes('data-selected')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('preventDeselect in multiple mode blocks removal but allows addition', async () => {
|
||||
const w = mountCalendar({
|
||||
multiple: true,
|
||||
preventDeselect: true,
|
||||
defaultValue: [new Date(2026, 5, 5)],
|
||||
defaultPlaceholder: new Date(2026, 5, 1),
|
||||
});
|
||||
const existing = () => w.find('[data-value="2026-06-05"]:not([data-outside-view])');
|
||||
await existing().trigger('click'); // attempt removal — blocked
|
||||
await nextTick();
|
||||
expect(existing().attributes('data-selected')).toBe('');
|
||||
|
||||
const next = () => w.find('[data-value="2026-06-09"]:not([data-outside-view])');
|
||||
await next().trigger('click'); // addition allowed
|
||||
await nextTick();
|
||||
expect(next().attributes('data-selected')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Calendar — disableDaysOutsideCurrentView', () => {
|
||||
it('disables adjacent-month days when enabled', () => {
|
||||
const w = mountCalendar({
|
||||
disableDaysOutsideCurrentView: true,
|
||||
defaultPlaceholder: new Date(2026, 5, 1), // June 2026 starts on a Monday → leading outside days
|
||||
});
|
||||
const outside = w.findAll('[data-primitives-calendar-cell-trigger][data-outside-view]');
|
||||
expect(outside.length).toBeGreaterThan(0);
|
||||
for (const t of outside)
|
||||
expect(t.attributes('data-disabled')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('leaves adjacent-month days enabled by default', () => {
|
||||
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1) });
|
||||
const outside = w.findAll('[data-primitives-calendar-cell-trigger][data-outside-view]');
|
||||
for (const t of outside)
|
||||
expect(t.attributes('data-disabled')).toBeUndefined();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Calendar — keyboard skips disabled days', () => {
|
||||
it('ArrowRight jumps over a disabled day to the next available one', async () => {
|
||||
const w = mountCalendar({
|
||||
defaultPlaceholder: new Date(2026, 5, 1),
|
||||
isDateDisabled: (d: Date) => d.getDate() === 11, // disable June 11
|
||||
}, { attachTo: document.body });
|
||||
const from = w.find('[data-value="2026-06-10"]:not([data-outside-view])');
|
||||
(from.element as HTMLElement).focus();
|
||||
press(from.element, 'ArrowRight');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(document.activeElement?.getAttribute('data-value')).toBe('2026-06-12');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('intra-month arrow navigation does not shift a multi-month visible window', async () => {
|
||||
const w = mountCalendar({
|
||||
defaultPlaceholder: new Date(2026, 5, 1),
|
||||
numberOfMonths: 2,
|
||||
}, { attachTo: document.body });
|
||||
// Both June and July grids are present before navigation.
|
||||
expect(w.findAll('[data-value="2026-06-15"]:not([data-outside-view])').length).toBe(1);
|
||||
expect(w.findAll('[data-value="2026-07-15"]:not([data-outside-view])').length).toBe(1);
|
||||
|
||||
const from = w.find('[data-value="2026-06-15"]:not([data-outside-view])');
|
||||
(from.element as HTMLElement).focus();
|
||||
press(from.element, 'ArrowRight');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
// Window unchanged: July is still rendered, June 16 received focus.
|
||||
expect(document.activeElement?.getAttribute('data-value')).toBe('2026-06-16');
|
||||
expect(w.findAll('[data-value="2026-07-15"]:not([data-outside-view])').length).toBe(1);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Calendar — roving fallback tabindex', () => {
|
||||
it('initial tab stop skips disabled leading days and lands on the first available day', () => {
|
||||
// Disable the entire first half of June; tab stop should land on the 16th.
|
||||
const w = mountCalendar({
|
||||
defaultPlaceholder: new Date(2026, 5, 1),
|
||||
isDateDisabled: (d: Date) => d.getMonth() === 5 && d.getDate() < 16,
|
||||
});
|
||||
const focusable = w.findAll('[data-primitives-calendar-cell-trigger][tabindex="0"]');
|
||||
expect(focusable).toHaveLength(1);
|
||||
expect(focusable[0]!.attributes('data-value')).toBe('2026-06-16');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Calendar — outside-visible-view marker', () => {
|
||||
it('marks single-month padding days (from adjacent months) as outside-visible-view', () => {
|
||||
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1), numberOfMonths: 1 });
|
||||
// With one visible month, an adjacent-month padding day is both
|
||||
// outside-view and outside-visible-view, and every such pair must agree.
|
||||
const outsideView = w.findAll('[data-primitives-calendar-cell-trigger][data-outside-view]');
|
||||
const outsideVisible = w.findAll('[data-primitives-calendar-cell-trigger][data-outside-visible-view]');
|
||||
expect(outsideVisible.length).toBe(outsideView.length);
|
||||
expect(outsideVisible.length).toBeGreaterThan(0);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('a padding day belonging to a second visible month is outside-view but not outside-visible-view', () => {
|
||||
// June + July 2026 visible. July 1 appears as a padding cell in June's grid:
|
||||
// outside June's view, yet July is a visible month → not outside-visible-view.
|
||||
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1), numberOfMonths: 2 });
|
||||
const julyFirstPadding = w.findAll('[data-value="2026-07-01"][data-outside-view]');
|
||||
expect(julyFirstPadding.length).toBeGreaterThan(0);
|
||||
for (const t of julyFirstPadding)
|
||||
expect(t.attributes('data-outside-visible-view')).toBeUndefined();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Calendar — locale-aware week start', () => {
|
||||
it('defaults weekStartsOn from the locale when the prop is omitted', () => {
|
||||
expect(getLocaleWeekStartsOn('en-US')).toBe(0);
|
||||
expect(getLocaleWeekStartsOn('fr-FR')).toBe(1);
|
||||
expect(getLocaleWeekStartsOn('de-DE')).toBe(1);
|
||||
});
|
||||
|
||||
it('an explicit weekStartsOn overrides the locale default', () => {
|
||||
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1), locale: 'fr-FR', weekStartsOn: 0 });
|
||||
// weekStartsOn=0 (Sunday): first cell of the first week is a Sunday.
|
||||
const firstTrigger = w.find('[data-primitives-calendar-cell-trigger]');
|
||||
const iso = firstTrigger.attributes('data-value')!;
|
||||
expect(new Date(`${iso}T00:00:00`).getDay()).toBe(0);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findFirstFocusableDate', () => {
|
||||
it('returns the first non-disabled, non-unavailable in-view date', () => {
|
||||
const months = [{ value: new Date(2026, 5, 1), weeks: [] }];
|
||||
const first = findFirstFocusableDate(
|
||||
months,
|
||||
d => d.getDate() < 5,
|
||||
() => false,
|
||||
);
|
||||
expect(first && toIsoDate(first)).toBe('2026-06-05');
|
||||
});
|
||||
|
||||
it('returns undefined when every day is blocked', () => {
|
||||
const months = [{ value: new Date(2026, 5, 1), weeks: [] }];
|
||||
expect(findFirstFocusableDate(months, () => true, () => false)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user