diff --git a/.changeset/famous-llamas-smoke.md b/.changeset/famous-llamas-smoke.md new file mode 100644 index 00000000..323ba850 --- /dev/null +++ b/.changeset/famous-llamas-smoke.md @@ -0,0 +1,5 @@ +--- +'@formwerk/core': patch +--- + +feat: implement HTML constraint validator diff --git a/packages/core/src/useCalendar/useCalendar.ts b/packages/core/src/useCalendar/useCalendar.ts index a26e3cc0..3295ac84 100644 --- a/packages/core/src/useCalendar/useCalendar.ts +++ b/packages/core/src/useCalendar/useCalendar.ts @@ -16,6 +16,7 @@ import { useInputValidity } from '../validation'; import { fromDateToCalendarZonedDateTime, useTemporalStore } from '../useDateTimeField/useTemporalStore'; import { PickerContextKey } from '../usePicker'; import { registerField } from '@formwerk/devtools'; +import { useConstraintsValidator } from '../validation/useContraintsValidator'; export interface CalendarProps { /** @@ -28,6 +29,11 @@ export interface CalendarProps { */ label: string; + /** + * Whether the calendar is required. + */ + required?: boolean; + /** * The locale to use for the calendar. */ @@ -148,7 +154,14 @@ export function useCalendar(_props: Reactivify { template: `
Date -
+
fromDateToCalendarZonedDateTime(toValue(props.min), calendar.value, timeZone.value)); const max = computed(() => fromDateToCalendarZonedDateTime(toValue(props.max), calendar.value, timeZone.value)); @@ -142,6 +157,7 @@ export function useDateTimeField(_props: Reactivify field.setTouched(true), min, max, + dispatchEvent: (type: string) => inputEl.value?.dispatchEvent(new Event(type)), }); const { labelProps, labelledByProps } = useLabel({ diff --git a/packages/core/src/useDateTimeField/useDateTimeSegment.ts b/packages/core/src/useDateTimeField/useDateTimeSegment.ts index 582dcf76..d2298701 100644 --- a/packages/core/src/useDateTimeField/useDateTimeSegment.ts +++ b/packages/core/src/useDateTimeField/useDateTimeSegment.ts @@ -1,4 +1,4 @@ -import { computed, CSSProperties, defineComponent, h, inject, shallowRef, toValue } from 'vue'; +import { computed, CSSProperties, defineComponent, h, inject, nextTick, shallowRef, toValue } from 'vue'; import { Reactivify } from '../types'; import { hasKeyCode, isNullOrUndefined, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; import { DateTimeSegmentGroupKey } from './useDateTimeSegmentGroup'; @@ -75,6 +75,7 @@ export function useDateTimeSegment(_props: Reactivify) { focusNext, isNumeric, isLockedByRange, + dispatchEvent, } = segmentGroup.useDateSegmentRegistration({ id, getElem: () => segmentEl.value, @@ -135,6 +136,9 @@ export function useDateTimeSegment(_props: Reactivify) { }, onBlur() { onTouched(); + nextTick(() => { + dispatchEvent('blur'); + }); const { min, max } = getMetadata(); if (isNullOrUndefined(min) || isNullOrUndefined(max)) { return; diff --git a/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.spec.ts b/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.spec.ts index 67e7be7d..00c86439 100644 --- a/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.spec.ts +++ b/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.spec.ts @@ -6,6 +6,10 @@ import { flush } from '@test-utils/flush'; import { DateTimeSegment } from './useDateTimeSegment'; import { createTemporalPartial, isTemporalPartial } from './temporalPartial'; +function dispatchEvent() { + // NOOP +} + describe('useDateTimeSegmentGroup', () => { const timeZone = 'UTC'; const locale = 'en-US'; @@ -35,6 +39,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); // Register a segment @@ -84,6 +89,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); return { @@ -133,9 +139,10 @@ describe('useDateTimeSegmentGroup', () => { formatOptions: {}, locale, controlEl, + direction: 'rtl', onValueChange, onTouched: () => {}, - direction: 'rtl', + dispatchEvent, }); return { @@ -187,6 +194,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); const segment = { @@ -222,6 +230,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); const segment = { @@ -257,6 +266,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); const segment = { @@ -292,6 +302,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); const segment = { @@ -329,6 +340,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); return { @@ -369,6 +381,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); return { @@ -430,6 +443,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); return { @@ -492,6 +506,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); return { @@ -548,6 +563,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); return { @@ -621,6 +637,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); return { @@ -718,6 +735,7 @@ describe('useDateTimeSegmentGroup', () => { controlEl, onValueChange, onTouched: () => {}, + dispatchEvent, }); return { diff --git a/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.ts b/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.ts index 464c3531..5696925c 100644 --- a/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.ts +++ b/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.ts @@ -36,6 +36,7 @@ export interface DateTimeSegmentGroupContext { isLast(): boolean; focusNext(): void; isLockedByRange(): boolean; + dispatchEvent(type: string): void; }; } @@ -53,6 +54,7 @@ export interface DateTimeSegmentGroupProps { max?: MaybeRefOrGetter>; onValueChange: (value: ZonedDateTime) => void; onTouched: () => void; + dispatchEvent: (type: string) => void; } export function useDateTimeSegmentGroup({ @@ -67,6 +69,7 @@ export function useDateTimeSegmentGroup({ max, onValueChange, onTouched, + dispatchEvent, }: DateTimeSegmentGroupProps) { const renderedSegments = ref([]); const parser = useNumberParser(locale, { @@ -267,6 +270,7 @@ export function useDateTimeSegmentGroup({ focusNext, isNumeric, isLockedByRange, + dispatchEvent, }; } @@ -388,7 +392,7 @@ function useDateArithmetic({ currentDate, min, max }: ArithmeticInit) { }; } - return clampDate(newDate); + return newDate; } function addToPart(part: DateTimeSegmentType, diff: number) { diff --git a/packages/core/src/useSelect/useSelect.ts b/packages/core/src/useSelect/useSelect.ts index b4621f22..3eb0fc6a 100644 --- a/packages/core/src/useSelect/useSelect.ts +++ b/packages/core/src/useSelect/useSelect.ts @@ -15,6 +15,7 @@ import { useListBox } from '../useListBox'; import { useLabel, useErrorMessage } from '../a11y'; import { FieldTypePrefixes } from '../constants'; import { registerField } from '@formwerk/devtools'; +import { useConstraintsValidator } from '../validation/useContraintsValidator'; export interface SelectProps { /** @@ -32,6 +33,11 @@ export interface SelectProps { */ description?: string; + /** + * Whether the select field is required. + */ + required?: boolean; + /** * Placeholder text when no option is selected. */ @@ -93,7 +99,7 @@ export function useSelect(_props: Reactivify(); const { fieldValue, setValue, errorMessage, isDisabled } = field; const isMutable = () => !isDisabled.value && !toValue(props.readonly); const { labelProps, labelledByProps } = useLabel({ @@ -126,7 +132,14 @@ export function useSelect(_props: Reactivify(_props: Reactivify(); - const triggerProps = computed(() => { return withRefCapture( { diff --git a/packages/core/src/validation/useContraintsValidator.spec.ts b/packages/core/src/validation/useContraintsValidator.spec.ts new file mode 100644 index 00000000..bebb2232 --- /dev/null +++ b/packages/core/src/validation/useContraintsValidator.spec.ts @@ -0,0 +1,283 @@ +import { nextTick, onMounted, ref } from 'vue'; +import { useConstraintsValidator } from './useContraintsValidator'; +import { fireEvent, render, screen } from '@testing-library/vue'; +import { renderSetup } from '@test-utils/index'; + +describe('useConstraintsValidator', () => { + describe('text constraints', () => { + test('creates an input element with text type', async () => { + const source = ref(); + const value = ref('test'); + + const { element } = await renderSetup(() => { + return useConstraintsValidator({ + type: 'text', + value, + source, + }); + }); + + expect(element.value?.type).toBe('text'); + expect(element.value?.value).toBe('test'); + }); + + test('sets required attribute', async () => { + const source = ref(); + const value = ref('test'); + const required = ref(true); + + const { element } = await renderSetup(() => { + return useConstraintsValidator({ + type: 'text', + value, + source, + required, + }); + }); + + expect(element.value?.required).toBe(true); + + // Change required value + required.value = false; + await nextTick(); + expect(element.value?.required).toBe(false); + }); + + test('sets minLength and maxLength attributes', async () => { + const source = ref(); + const value = ref('test'); + const minLength = ref(2); + const maxLength = ref(10); + + const { element } = await renderSetup(() => { + return useConstraintsValidator({ + type: 'text', + value, + source, + minLength, + maxLength, + }); + }); + + expect(element.value?.getAttribute('minlength')).toBe('2'); + expect(element.value?.getAttribute('maxlength')).toBe('10'); + + // Change min/max values + minLength.value = 3; + maxLength.value = 20; + await nextTick(); + expect(element.value?.getAttribute('minlength')).toBe('3'); + expect(element.value?.getAttribute('maxlength')).toBe('20'); + }); + + test('updates value when source value changes', async () => { + const source = ref(); + const value = ref('test'); + + const { element } = await renderSetup(() => { + return useConstraintsValidator({ + type: 'text', + value, + source, + }); + }); + + expect(element.value?.value).toBe('test'); + + value.value = 'updated'; + await nextTick(); + expect(element.value?.value).toBe('updated'); + }); + }); + + describe('select constraints', () => { + test('creates an input element with text type for select', async () => { + const source = ref(); + const value = ref('option1'); + + const { element } = await renderSetup(() => { + return useConstraintsValidator({ + type: 'select', + value, + source, + }); + }); + + expect(element.value?.type).toBe('text'); + expect(element.value?.value).toBe('option1'); + }); + }); + + describe('number constraints', () => { + test('creates an input element with number type', async () => { + const source = ref(); + const value = ref(42); + + const { element } = await renderSetup(() => { + return useConstraintsValidator({ + type: 'number', + value, + source, + }); + }); + + expect(element.value?.type).toBe('number'); + expect(element.value?.value).toBe('42'); + }); + + test('sets min and max attributes', async () => { + const source = ref(); + const value = ref(42); + const min = ref(0); + const max = ref(100); + + const { element } = await renderSetup(() => { + return useConstraintsValidator({ + type: 'number', + value, + source, + min, + max, + }); + }); + + expect(element.value?.getAttribute('min')).toBe('0'); + expect(element.value?.getAttribute('max')).toBe('100'); + + // Change min/max values + min.value = 10; + max.value = 200; + await nextTick(); + expect(element.value?.getAttribute('min')).toBe('10'); + expect(element.value?.getAttribute('max')).toBe('200'); + }); + }); + + describe('date constraints', () => { + test('creates an input element with date type', async () => { + const source = ref(); + const value = ref(new Date('2023-01-01')); + + const { element } = await renderSetup(() => { + return useConstraintsValidator({ + type: 'date', + value, + source, + }); + }); + + expect(element.value?.type).toBe('date'); + expect(element.value?.value).toBe('2023-01-01'); + }); + + test('sets min and max date attributes', async () => { + const source = ref(); + const value = ref(new Date('2023-01-15')); + const min = ref(new Date('2023-01-01')); + const max = ref(new Date('2023-01-31')); + + const { element } = await renderSetup(() => { + return useConstraintsValidator({ + type: 'date', + value, + source, + min, + max, + }); + }); + + expect(element.value?.getAttribute('min')).toBe('2023-01-01'); + expect(element.value?.getAttribute('max')).toBe('2023-01-31'); + + // Change min/max values + min.value = new Date('2023-02-01'); + max.value = new Date('2023-02-28'); + await nextTick(); + expect(element.value?.getAttribute('min')).toBe('2023-02-01'); + expect(element.value?.getAttribute('max')).toBe('2023-02-28'); + }); + + test('handles null date values', async () => { + const source = ref(); + const value = ref(null); + + const { element } = await renderSetup(() => { + return useConstraintsValidator({ + type: 'date', + value, + source, + }); + }); + + expect(element.value?.value).toBe(''); + + value.value = new Date('2023-03-15'); + await nextTick(); + expect(element.value?.value).toBe('2023-03-15'); + }); + }); + + describe('event handling', () => { + test('forwards events from source to element', async () => { + const source = ref(); + const value = ref('test'); + const dispatchedEvents: string[] = []; + + await render({ + setup: () => { + const { element } = useConstraintsValidator({ + type: 'text', + value, + source, + }); + + onMounted(() => { + // Mock the dispatchEvent method to track events + element.value!.dispatchEvent = ((event: Event) => { + dispatchedEvents.push(event.type); + return true; + }) as any; + }); + + return { source }; + }, + template: `
`, + }); + + await nextTick(); + + // Trigger events on the source element + await fireEvent.change(screen.getByTestId('source')); + await fireEvent.blur(screen.getByTestId('source')); + await fireEvent.input(screen.getByTestId('source')); + + // Check if events were forwarded to the element + expect(dispatchedEvents).toContain('change'); + expect(dispatchedEvents).toContain('blur'); + expect(dispatchedEvents).toContain('input'); + }); + }); + + describe('edge cases', () => { + test('handles undefined constraints values', async () => { + const source = ref(); + const value = ref(undefined); + + const { element } = await renderSetup(() => { + return useConstraintsValidator({ + type: 'text', + value, + source, + minLength: undefined, + maxLength: undefined, + required: undefined, + }); + }); + + expect(element.value?.required).toBe(false); + expect(element.value?.getAttribute('minlength')).toBe(''); + expect(element.value?.getAttribute('maxlength')).toBe(''); + expect(element.value?.value).toBe(''); + }); + }); +}); diff --git a/packages/core/src/validation/useContraintsValidator.ts b/packages/core/src/validation/useContraintsValidator.ts new file mode 100644 index 00000000..119d5bad --- /dev/null +++ b/packages/core/src/validation/useContraintsValidator.ts @@ -0,0 +1,115 @@ +import { MaybeRefOrGetter, nextTick, onMounted, Ref, shallowRef, toValue, watchEffect } from 'vue'; +import { Maybe } from '../types'; +import { useEventListener } from '../helpers/useEventListener'; + +export interface ConstraintOptions { + value: MaybeRefOrGetter>; + source: Ref>; +} + +interface BaseConstraints extends ConstraintOptions { + required?: MaybeRefOrGetter>; +} + +interface TextualConstraints extends BaseConstraints { + type: 'text'; + minLength?: MaybeRefOrGetter>; + maxLength?: MaybeRefOrGetter>; +} + +interface SelectConstraints extends BaseConstraints { + type: 'select'; +} + +interface NumericConstraints extends BaseConstraints { + type: 'number'; + min?: MaybeRefOrGetter>; + max?: MaybeRefOrGetter>; +} + +interface DateConstraints extends BaseConstraints { + type: 'date'; + min?: MaybeRefOrGetter>; + max?: MaybeRefOrGetter>; +} + +export type Constraints = TextualConstraints | SelectConstraints | NumericConstraints | DateConstraints; + +export function useConstraintsValidator(constraints: Constraints) { + const element = shallowRef(); + + onMounted(() => { + element.value = document.createElement('input'); + element.value.type = constraints.type === 'select' ? 'text' : constraints.type; + }); + + watchEffect(() => { + if (!element.value) { + return; + } + + element.value.required = toValue(constraints.required) ?? false; + + if (constraints.type === 'text') { + element.value.setAttribute('minlength', toValue(constraints.minLength)?.toString() ?? ''); + element.value.setAttribute('maxlength', toValue(constraints.maxLength)?.toString() ?? ''); + + return; + } + + if (constraints.type === 'number') { + element.value.setAttribute('min', toValue(constraints.min)?.toString() ?? ''); + element.value.setAttribute('max', toValue(constraints.max)?.toString() ?? ''); + + return; + } + + if (constraints.type === 'date') { + const min = toValue(constraints.min); + const max = toValue(constraints.max); + element.value.setAttribute('min', min ? dateToString(min) : ''); + element.value.setAttribute('max', max ? dateToString(max) : ''); + + return; + } + }); + + watchEffect(() => { + if (!element.value) { + return; + } + + nextTick(() => { + element.value?.dispatchEvent(new Event('change')); + }); + + if (constraints.type === 'text' || element.value.type === 'text') { + const val = toValue(constraints.value); + element.value.value = String(val ?? ''); + return; + } + + if (constraints.type === 'number') { + const val = toValue(constraints.value); + element.value.value = String(val ?? ''); + return; + } + + if (constraints.type === 'date') { + const date = toValue(constraints.value); + element.value.value = date ? dateToString(date) : ''; + } + }); + + useEventListener(constraints.source, ['blur', 'input'], evt => { + element.value?.dispatchEvent(new Event(evt.type)); + }); + + return { + element, + }; +} + +function dateToString(date: Date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 83d7f8ca..1d15a8a0 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -11,6 +11,8 @@ import Switch from '@/components/Switch.vue'; import InputTextArea from '@/components/InputTextArea.vue'; import { ref } from 'vue'; import AllForm from './components/AllForm.vue'; +import Slider from './components/Slider.vue'; +import Calendar from './components/Calendar.vue'; const min = new Date(2025, 0, 4, 0, 0, 0, 0); const value = new Date('2025-01-15'); @@ -27,14 +29,15 @@ const isNotificationsEnabled = ref(false);