Skip to content

Commit

Permalink
feat: implement constraint simulator (#147)
Browse files Browse the repository at this point in the history
* feat: basic implementation of constraints simulator

* feat: apply the validator on date fields

* fix: wait for next tick to emit events to allow time for value change

* chore: add changeset

* feat: add constraints validation to the calendar

* fix: uneccessary prop wrapping
  • Loading branch information
logaretm authored Mar 2, 2025
1 parent a764650 commit ce583ea
Show file tree
Hide file tree
Showing 13 changed files with 498 additions and 16 deletions.
5 changes: 5 additions & 0 deletions .changeset/famous-llamas-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@formwerk/core': patch
---

feat: implement HTML constraint validator
15 changes: 14 additions & 1 deletion packages/core/src/useCalendar/useCalendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -28,6 +29,11 @@ export interface CalendarProps {
*/
label: string;

/**
* Whether the calendar is required.
*/
required?: boolean;

/**
* The locale to use for the calendar.
*/
Expand Down Expand Up @@ -148,7 +154,14 @@ export function useCalendar(_props: Reactivify<CalendarProps, 'field' | 'schema'

// If no controlling field is provided, we should hook up the required hooks to promote the calender to a full form field.
if (!props.field) {
useInputValidity({ field });
const { element } = useConstraintsValidator({
type: 'date',
value: field.fieldValue,
source: calendarEl,
required: props.required,
});

useInputValidity({ field, inputEl: element });
}

const isDisabled = createDisabledContext(props.disabled);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ describe('useDateTimeField', () => {
template: `
<div>
<span v-bind="labelProps">Date</span>
<div v-bind="controlProps">
<div v-bind="controlProps" data-testid="control">
<DateTimeSegment
v-for="segment in segments"
:key="segment.type"
Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/useDateTimeField/useDateTimeField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ZonedDateTime, Calendar } from '@internationalized/date';
import { useInputValidity } from '../validation';
import { createDisabledContext } from '../helpers/createDisabledContext';
import { registerField } from '@formwerk/devtools';
import { useConstraintsValidator } from '../validation/useContraintsValidator';

export interface DateTimeFieldProps {
/**
Expand All @@ -24,6 +25,11 @@ export interface DateTimeFieldProps {
*/
name?: string;

/**
* Whether the field is required.
*/
required?: boolean;

/**
* The locale to use for the field.
*/
Expand Down Expand Up @@ -109,7 +115,16 @@ export function useDateTimeField(_props: Reactivify<DateTimeFieldProps, 'schema'
schema: props.schema,
});

useInputValidity({ field });
const { element: inputEl } = useConstraintsValidator({
type: 'date',
required: props.required,
value: field.fieldValue,
source: controlEl,
min: props.min,
max: props.max,
});

useInputValidity({ field, inputEl });

const min = computed(() => fromDateToCalendarZonedDateTime(toValue(props.min), calendar.value, timeZone.value));
const max = computed(() => fromDateToCalendarZonedDateTime(toValue(props.max), calendar.value, timeZone.value));
Expand Down Expand Up @@ -142,6 +157,7 @@ export function useDateTimeField(_props: Reactivify<DateTimeFieldProps, 'schema'
onTouched: () => field.setTouched(true),
min,
max,
dispatchEvent: (type: string) => inputEl.value?.dispatchEvent(new Event(type)),
});

const { labelProps, labelledByProps } = useLabel({
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/useDateTimeField/useDateTimeSegment.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -75,6 +75,7 @@ export function useDateTimeSegment(_props: Reactivify<DateTimeSegmentProps>) {
focusNext,
isNumeric,
isLockedByRange,
dispatchEvent,
} = segmentGroup.useDateSegmentRegistration({
id,
getElem: () => segmentEl.value,
Expand Down Expand Up @@ -135,6 +136,9 @@ export function useDateTimeSegment(_props: Reactivify<DateTimeSegmentProps>) {
},
onBlur() {
onTouched();
nextTick(() => {
dispatchEvent('blur');
});
const { min, max } = getMetadata();
if (isNullOrUndefined(min) || isNullOrUndefined(max)) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,6 +39,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

// Register a segment
Expand Down Expand Up @@ -84,6 +89,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

return {
Expand Down Expand Up @@ -133,9 +139,10 @@ describe('useDateTimeSegmentGroup', () => {
formatOptions: {},
locale,
controlEl,
direction: 'rtl',
onValueChange,
onTouched: () => {},
direction: 'rtl',
dispatchEvent,
});

return {
Expand Down Expand Up @@ -187,6 +194,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

const segment = {
Expand Down Expand Up @@ -222,6 +230,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

const segment = {
Expand Down Expand Up @@ -257,6 +266,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

const segment = {
Expand Down Expand Up @@ -292,6 +302,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

const segment = {
Expand Down Expand Up @@ -329,6 +340,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

return {
Expand Down Expand Up @@ -369,6 +381,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

return {
Expand Down Expand Up @@ -430,6 +443,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

return {
Expand Down Expand Up @@ -492,6 +506,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

return {
Expand Down Expand Up @@ -548,6 +563,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

return {
Expand Down Expand Up @@ -621,6 +637,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

return {
Expand Down Expand Up @@ -718,6 +735,7 @@ describe('useDateTimeSegmentGroup', () => {
controlEl,
onValueChange,
onTouched: () => {},
dispatchEvent,
});

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface DateTimeSegmentGroupContext {
isLast(): boolean;
focusNext(): void;
isLockedByRange(): boolean;
dispatchEvent(type: string): void;
};
}

Expand All @@ -53,6 +54,7 @@ export interface DateTimeSegmentGroupProps {
max?: MaybeRefOrGetter<Maybe<ZonedDateTime>>;
onValueChange: (value: ZonedDateTime) => void;
onTouched: () => void;
dispatchEvent: (type: string) => void;
}

export function useDateTimeSegmentGroup({
Expand All @@ -67,6 +69,7 @@ export function useDateTimeSegmentGroup({
max,
onValueChange,
onTouched,
dispatchEvent,
}: DateTimeSegmentGroupProps) {
const renderedSegments = ref<DateTimeSegmentRegistration[]>([]);
const parser = useNumberParser(locale, {
Expand Down Expand Up @@ -267,6 +270,7 @@ export function useDateTimeSegmentGroup({
focusNext,
isNumeric,
isLockedByRange,
dispatchEvent,
};
}

Expand Down Expand Up @@ -388,7 +392,7 @@ function useDateArithmetic({ currentDate, min, max }: ArithmeticInit) {
};
}

return clampDate(newDate);
return newDate;
}

function addToPart(part: DateTimeSegmentType, diff: number) {
Expand Down
19 changes: 15 additions & 4 deletions packages/core/src/useSelect/useSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TOption, TValue = TOption> {
/**
Expand All @@ -32,6 +33,11 @@ export interface SelectProps<TOption, TValue = TOption> {
*/
description?: string;

/**
* Whether the select field is required.
*/
required?: boolean;

/**
* Placeholder text when no option is selected.
*/
Expand Down Expand Up @@ -93,7 +99,7 @@ export function useSelect<TOption, TValue = TOption>(_props: Reactivify<SelectPr
disabled: props.disabled,
schema: props.schema,
});

const triggerEl = ref<HTMLElement>();
const { fieldValue, setValue, errorMessage, isDisabled } = field;
const isMutable = () => !isDisabled.value && !toValue(props.readonly);
const { labelProps, labelledByProps } = useLabel({
Expand Down Expand Up @@ -126,7 +132,14 @@ export function useSelect<TOption, TValue = TOption>(_props: Reactivify<SelectPr
onToggleAfter: toggleAfter,
});

const { updateValidity } = useInputValidity({ field });
const { element: inputEl } = useConstraintsValidator({
type: 'select',
required: props.required,
value: fieldValue as unknown as string,
source: triggerEl,
});

const { updateValidity } = useInputValidity({ field, inputEl });
const { descriptionProps, describedByProps } = createDescribedByProps({
inputId,
description: props.description,
Expand Down Expand Up @@ -251,8 +264,6 @@ export function useSelect<TOption, TValue = TOption>(_props: Reactivify<SelectPr
},
};

const triggerEl = ref<HTMLElement>();

const triggerProps = computed<SelectTriggerDomProps>(() => {
return withRefCapture(
{
Expand Down
Loading

0 comments on commit ce583ea

Please sign in to comment.