diff --git a/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.spec.ts b/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.spec.ts new file mode 100644 index 00000000..6db82af9 --- /dev/null +++ b/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.spec.ts @@ -0,0 +1,594 @@ +import { DateFormatter, now } from '@internationalized/date'; +import { useDateTimeSegmentGroup } from './useDateTimeSegmentGroup'; +import { ref } from 'vue'; +import { fireEvent, render, screen } from '@testing-library/vue'; +import { flush } from '@test-utils/flush'; +import { DateTimeSegment } from './useDateTimeSegment'; + +describe('useDateTimeSegmentGroup', () => { + const timeZone = 'UTC'; + const locale = 'en-US'; + const currentDate = now(timeZone); + + function createFormatter() { + return new DateFormatter(locale, { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }); + } + + describe('segment registration', () => { + test('registers and unregisters segments', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + setup() { + const { segments, useDateSegmentRegistration } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + }); + + // Register a segment + const segment = { + id: 'test-segment', + getType: () => 'day' as const, + getElem: () => document.createElement('div'), + }; + + const registration = useDateSegmentRegistration(segment); + + return { + segments, + registration, + }; + }, + template: ` +
+
+ {{ segment.value }} +
+
+ `, + }); + + await flush(); + expect(onValueChange).not.toHaveBeenCalled(); + }); + }); + + describe('segment navigation', () => { + test('handles keyboard navigation between segments', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ + +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment').filter(el => el.dataset.segmentType !== 'literal'); + segments[0].focus(); + + // Test right arrow navigation + await fireEvent.keyDown(segments[0], { code: 'ArrowRight' }); + expect(document.activeElement).toBe(segments[1]); + + // Test left arrow navigation + await fireEvent.keyDown(segments[1], { code: 'ArrowLeft' }); + expect(document.activeElement).toBe(segments[0]); + }); + + test('respects RTL direction', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + direction: 'rtl', + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ + +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment').filter(el => el.dataset.segmentType !== 'literal'); + segments[0].focus(); + + // Test right arrow navigation (should go left in RTL) + await fireEvent.keyDown(segments[1], { code: 'ArrowRight' }); + expect(document.activeElement).toBe(segments[0]); + + // Test left arrow navigation (should go right in RTL) + await fireEvent.keyDown(segments[0], { code: 'ArrowLeft' }); + expect(document.activeElement).toBe(segments[1]); + }); + }); + + describe('value updates', () => { + test('increments segment values', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + let monthRegistration!: ReturnType['useDateSegmentRegistration']>; + + await render({ + setup() { + const { useDateSegmentRegistration } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + }); + + const segment = { + id: 'month-segment', + getType: () => 'month' as const, + getElem: () => document.createElement('div'), + }; + + monthRegistration = useDateSegmentRegistration(segment) as any; + + return {}; + }, + template: '
', + }); + + monthRegistration.increment(); + expect(onValueChange).toHaveBeenCalledWith(currentDate.add({ months: 1 })); + }); + + test('decrements segment values', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + let monthRegistration!: ReturnType['useDateSegmentRegistration']>; + + await render({ + setup() { + const { useDateSegmentRegistration } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + }); + + const segment = { + id: 'month-segment', + getType: () => 'month' as const, + getElem: () => document.createElement('div'), + }; + + monthRegistration = useDateSegmentRegistration(segment) as any; + + return {}; + }, + template: '
', + }); + + monthRegistration.decrement(); + expect(onValueChange).toHaveBeenCalledWith(currentDate.subtract({ months: 1 })); + }); + + test('sets specific segment values', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + let monthRegistration!: ReturnType['useDateSegmentRegistration']>; + + await render({ + setup() { + const { useDateSegmentRegistration } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + }); + + const segment = { + id: 'month-segment', + getType: () => 'month' as const, + getElem: () => document.createElement('div'), + }; + + monthRegistration = useDateSegmentRegistration(segment) as any; + + return {}; + }, + template: '
', + }); + + monthRegistration.setValue(6); + expect(onValueChange).toHaveBeenCalledWith(currentDate.set({ month: 6 })); + }); + + test('clears segment values', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + let monthRegistration!: ReturnType['useDateSegmentRegistration']>; + + await render({ + setup() { + const { useDateSegmentRegistration } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + }); + + const segment = { + id: 'month-segment', + getType: () => 'month' as const, + getElem: () => document.createElement('div'), + }; + + monthRegistration = useDateSegmentRegistration(segment) as any; + + return {}; + }, + template: '
', + }); + + monthRegistration.clear() as any; + const lastCall = onValueChange.mock.lastCall?.[0]; + expect(lastCall['~fw_temporal_partial'].month).toBe(false); + }); + }); + + describe('formatting', () => { + test('formats segments according to locale', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale: 'de-DE', + controlEl, + onValueChange, + }); + + return { + segments, + }; + }, + template: ` +
+ + {{ segment.value }} + +
+ `, + }); + + await flush(); + const monthSegment = document.querySelector('[data-testid="month"]'); + expect(monthSegment?.textContent?.trim()).toBe(currentDate.month.toString()); + }); + }); + + describe('segment input handling', () => { + test('handles numeric input', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ + +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + const monthSegment = segments.find(el => el.dataset.segmentType === 'month')!; + monthSegment.focus(); + + // Test valid numeric input + const inputEvent = new InputEvent('beforeinput', { data: '1', cancelable: true }); + monthSegment.dispatchEvent(inputEvent); + expect(monthSegment.textContent).toBe('1'); + + // Test input completion on max length + const secondInputEvent = new InputEvent('beforeinput', { data: '2', cancelable: true }); + monthSegment.dispatchEvent(secondInputEvent); + expect(monthSegment.textContent).toBe('12'); + expect(document.activeElement).not.toBe(monthSegment); // Should move to next segment + + // Test invalid input (out of range) + monthSegment.focus(); + const invalidInputEvent = new InputEvent('beforeinput', { data: '13', cancelable: true }); + monthSegment.dispatchEvent(invalidInputEvent); + expect(monthSegment.textContent).not.toBe('13'); + }); + + test('handles keyboard navigation and actions', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ + +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + const monthSegment = segments.find(el => el.dataset.segmentType === 'month')!; + monthSegment.focus(); + + // Test increment with arrow up + await fireEvent.keyDown(monthSegment, { code: 'ArrowUp' }); + expect(onValueChange).toHaveBeenCalledWith(currentDate.add({ months: 1 })); + + // Test decrement with arrow down + await fireEvent.keyDown(monthSegment, { code: 'ArrowDown' }); + expect(onValueChange).toHaveBeenCalledWith(currentDate.subtract({ months: 1 })); + + // Test clearing with backspace + await fireEvent.keyDown(monthSegment, { code: 'Backspace' }); + const lastCall = onValueChange.mock.lastCall?.[0]; + expect(lastCall['~fw_temporal_partial'].month).toBe(false); + + // Test clearing with delete + await fireEvent.keyDown(monthSegment, { code: 'Delete' }); + const finalCall = onValueChange.mock.lastCall?.[0]; + expect(finalCall['~fw_temporal_partial'].month).toBe(false); + }); + + test('handles non-numeric input', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ + +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + const monthSegment = segments.find(el => el.dataset.segmentType === 'month')!; + monthSegment.focus(); + + // Test non-numeric input + const nonNumericEvent = new InputEvent('beforeinput', { data: 'a', cancelable: true }); + monthSegment.dispatchEvent(nonNumericEvent); + expect(nonNumericEvent.defaultPrevented).toBe(true); + expect(monthSegment.textContent).not.toBe('a'); + }); + + test('handles non-numeric segments (dayPeriod)', async () => { + const formatter = ref( + new DateFormatter(locale, { + hour: 'numeric', + hour12: true, + dayPeriod: 'short', + }), + ); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: { hour12: true }, + locale, + controlEl, + onValueChange, + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ + +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + const dayPeriodSegment = segments.find(el => el.dataset.segmentType === 'dayPeriod')!; + dayPeriodSegment.focus(); + + // Test numeric input is blocked + const inputEvent = new InputEvent('beforeinput', { data: '1', cancelable: true }); + dayPeriodSegment.dispatchEvent(inputEvent); + expect(inputEvent.defaultPrevented).toBe(true); + expect(dayPeriodSegment.textContent).not.toBe('1'); + + // Test arrow up changes period (AM -> PM) + await fireEvent.keyDown(dayPeriodSegment, { code: 'ArrowUp' }); + expect(onValueChange).toHaveBeenCalledWith(currentDate.add({ hours: 12 }).set({ day: currentDate.day })); + + // Test arrow down changes period (PM -> AM) + await fireEvent.keyDown(dayPeriodSegment, { code: 'ArrowDown' }); + expect(onValueChange).toHaveBeenCalledWith(currentDate.subtract({ hours: 12 })); + + // Test clearing with backspace + await fireEvent.keyDown(dayPeriodSegment, { code: 'Backspace' }); + const lastCall = onValueChange.mock.lastCall?.[0]; + expect(lastCall['~fw_temporal_partial'].dayPeriod).toBe(false); + + // Test clearing with delete + await fireEvent.keyDown(dayPeriodSegment, { code: 'Delete' }); + const finalCall = onValueChange.mock.lastCall?.[0]; + expect(finalCall['~fw_temporal_partial'].dayPeriod).toBe(false); + }); + }); +});