From ee240a1968bbb7249e1e0b409c582ce710ec5567 Mon Sep 17 00:00:00 2001 From: Hunter Johnston <64506580+huntabyte@users.noreply.github.com> Date: Fri, 27 Sep 2024 20:30:18 -0400 Subject: [PATCH] next: date field `onInvalid` and `validate` props (#683) --- .../src/lib/bits/command/command.svelte.ts | 12 +++- .../command/components/command-empty.svelte | 2 + .../bits-ui/src/lib/bits/command/types.ts | 8 ++- .../components/date-field-error.svelte | 0 .../date-field/components/date-field.svelte | 6 +- .../lib/bits/date-field/date-field.svelte.ts | 60 ++++++++++++++++--- .../bits-ui/src/lib/bits/date-field/types.ts | 24 +++++--- .../date-picker/components/date-picker.svelte | 9 ++- .../bits-ui/src/lib/bits/date-picker/types.ts | 20 ++++++- .../components/date-range-field.svelte | 6 +- .../date-range-field.svelte.ts | 19 ++++-- .../src/lib/bits/date-range-field/types.ts | 19 ++++-- .../components/date-range-picker.svelte | 9 ++- .../src/lib/bits/date-range-picker/types.ts | 24 +++++++- packages/bits-ui/src/lib/shared/date/types.ts | 13 ++++ .../src/tests/date-field/date-field.test.ts | 2 +- sites/docs/src/lib/components/search.svelte | 15 +++-- .../lib/content/api-reference/command.api.ts | 5 ++ .../content/api-reference/date-field.api.ts | 10 +++- .../content/api-reference/date-picker.api.ts | 2 + .../api-reference/date-range-field.api.ts | 3 +- .../api-reference/date-range-picker.api.ts | 2 + .../shared/date-on-invalid-prop.md | 3 + .../shared/date-validate-prop.md | 3 + .../extended-types/shared/index.ts | 2 + 25 files changed, 223 insertions(+), 55 deletions(-) create mode 100644 packages/bits-ui/src/lib/bits/date-field/components/date-field-error.svelte create mode 100644 sites/docs/src/lib/content/api-reference/extended-types/shared/date-on-invalid-prop.md create mode 100644 sites/docs/src/lib/content/api-reference/extended-types/shared/date-validate-prop.md diff --git a/packages/bits-ui/src/lib/bits/command/command.svelte.ts b/packages/bits-ui/src/lib/bits/command/command.svelte.ts index e8e611cf7..c16dd0f13 100644 --- a/packages/bits-ui/src/lib/bits/command/command.svelte.ts +++ b/packages/bits-ui/src/lib/bits/command/command.svelte.ts @@ -549,21 +549,29 @@ class CommandRootState { } } -type CommandEmptyStateProps = WithRefProps; +type CommandEmptyStateProps = WithRefProps & + ReadableBoxedValues<{ + forceMount: boolean; + }>; class CommandEmptyState { #ref: CommandEmptyStateProps["ref"]; #id: CommandEmptyStateProps["id"]; #root: CommandRootState; + #forceMount: CommandEmptyStateProps["forceMount"]; #isInitialRender = true; + shouldRender = $derived.by( - () => this.#root._commandState.filtered.count === 0 && this.#isInitialRender === false + () => + (this.#root._commandState.filtered.count === 0 && this.#isInitialRender === false) || + this.#forceMount.current ); constructor(props: CommandEmptyStateProps, root: CommandRootState) { this.#ref = props.ref; this.#id = props.id; this.#root = root; + this.#forceMount = props.forceMount; $effect(() => { this.#isInitialRender = false; diff --git a/packages/bits-ui/src/lib/bits/command/components/command-empty.svelte b/packages/bits-ui/src/lib/bits/command/components/command-empty.svelte index 5e60188f9..48662ed59 100644 --- a/packages/bits-ui/src/lib/bits/command/components/command-empty.svelte +++ b/packages/bits-ui/src/lib/bits/command/components/command-empty.svelte @@ -10,6 +10,7 @@ ref = $bindable(null), children, child, + forceMount = false, ...restProps }: EmptyProps = $props(); @@ -19,6 +20,7 @@ () => ref, (v) => (ref = v) ), + forceMount: box.with(() => forceMount), }); const mergedProps = $derived(mergeProps(emptyState.props, restProps)); diff --git a/packages/bits-ui/src/lib/bits/command/types.ts b/packages/bits-ui/src/lib/bits/command/types.ts index 330b8559c..e3369dd13 100644 --- a/packages/bits-ui/src/lib/bits/command/types.ts +++ b/packages/bits-ui/src/lib/bits/command/types.ts @@ -88,7 +88,13 @@ export type CommandRootPropsWithoutHTML = WithChild<{ export type CommandRootProps = CommandRootPropsWithoutHTML & Without; -export type CommandEmptyPropsWithoutHTML = WithChild; +export type CommandEmptyPropsWithoutHTML = WithChild<{ + /** + * Whether to force mount the group container regardless of + * filtering logic. + */ + forceMount?: boolean; +}>; export type CommandEmptyProps = CommandEmptyPropsWithoutHTML & Without; diff --git a/packages/bits-ui/src/lib/bits/date-field/components/date-field-error.svelte b/packages/bits-ui/src/lib/bits/date-field/components/date-field-error.svelte new file mode 100644 index 000000000..e69de29bb diff --git a/packages/bits-ui/src/lib/bits/date-field/components/date-field.svelte b/packages/bits-ui/src/lib/bits/date-field/components/date-field.svelte index 074d82775..6e6b5a460 100644 --- a/packages/bits-ui/src/lib/bits/date-field/components/date-field.svelte +++ b/packages/bits-ui/src/lib/bits/date-field/components/date-field.svelte @@ -16,7 +16,8 @@ minValue, onPlaceholderChange = noop, onValueChange = noop, - isDateInvalid, + validate = noop, + onInvalid = noop, placeholder = $bindable(), value = $bindable(), readonly = false, @@ -71,10 +72,11 @@ locale: box.with(() => locale), maxValue: box.with(() => maxValue), minValue: box.with(() => minValue), - isDateInvalid: box.with(() => isDateInvalid), + validate: box.with(() => validate), readonly: box.with(() => readonly), readonlySegments: box.with(() => readonlySegments), required: box.with(() => required), + onInvalid: box.with(() => onInvalid), }); diff --git a/packages/bits-ui/src/lib/bits/date-field/date-field.svelte.ts b/packages/bits-ui/src/lib/bits/date-field/date-field.svelte.ts index 26b93c0e6..b50f499cb 100644 --- a/packages/bits-ui/src/lib/bits/date-field/date-field.svelte.ts +++ b/packages/bits-ui/src/lib/bits/date-field/date-field.svelte.ts @@ -53,7 +53,12 @@ import type { SegmentPart } from "$lib/shared/index.js"; import { DATE_SEGMENT_PARTS, TIME_SEGMENT_PARTS } from "$lib/shared/date/field/parts.js"; import { createContext } from "$lib/internal/createContext.js"; import { useId } from "$lib/internal/useId.js"; -import type { DateMatcher, Granularity, HourCycle } from "$lib/shared/date/types.js"; +import type { + DateOnInvalid, + DateValidator, + Granularity, + HourCycle, +} from "$lib/shared/date/types.js"; import { onDestroyEffect } from "$lib/internal/onDestroyEffect.svelte.js"; export const DATE_FIELD_INPUT_ATTR = "data-date-field-input"; @@ -65,7 +70,8 @@ export type DateFieldRootStateProps = WritableBoxedValues<{ }> & ReadableBoxedValues<{ readonlySegments: SegmentPart[]; - isDateInvalid: DateMatcher | undefined; + validate: DateValidator | undefined; + onInvalid: DateOnInvalid | undefined; minValue: DateValue | undefined; maxValue: DateValue | undefined; disabled: boolean; @@ -80,7 +86,7 @@ export type DateFieldRootStateProps = WritableBoxedValues<{ export class DateFieldRootState { value: DateFieldRootStateProps["value"]; placeholder: WritableBox; - isDateInvalid: DateFieldRootStateProps["isDateInvalid"]; + validate: DateFieldRootStateProps["validate"]; minValue: DateFieldRootStateProps["minValue"]; maxValue: DateFieldRootStateProps["maxValue"]; disabled: DateFieldRootStateProps["disabled"]; @@ -91,6 +97,7 @@ export class DateFieldRootState { locale: DateFieldRootStateProps["locale"]; hideTimeZone: DateFieldRootStateProps["hideTimeZone"]; required: DateFieldRootStateProps["required"]; + onInvalid: DateFieldRootStateProps["onInvalid"]; descriptionId = useId(); formatter: Formatter; initialSegments: SegmentValueObj; @@ -116,7 +123,7 @@ export class DateFieldRootState { */ this.value = props.value; this.placeholder = rangeRoot ? rangeRoot.placeholder : props.placeholder; - this.isDateInvalid = rangeRoot ? rangeRoot.isDateInvalid : props.isDateInvalid; + this.validate = rangeRoot ? rangeRoot.validate : props.validate; this.minValue = rangeRoot ? rangeRoot.minValue : props.minValue; this.maxValue = rangeRoot ? rangeRoot.maxValue : props.maxValue; this.disabled = rangeRoot ? rangeRoot.disabled : props.disabled; @@ -127,6 +134,7 @@ export class DateFieldRootState { this.locale = rangeRoot ? rangeRoot.locale : props.locale; this.hideTimeZone = rangeRoot ? rangeRoot.hideTimeZone : props.hideTimeZone; this.required = rangeRoot ? rangeRoot.required : props.required; + this.onInvalid = rangeRoot ? rangeRoot.onInvalid : props.onInvalid; this.formatter = createFormatter(this.locale.current); this.initialSegments = initializeSegmentValues(this.inferredGranularity); this.segmentValues = this.initialSegments; @@ -181,6 +189,18 @@ export class DateFieldRootState { this.clearUpdating(); }); + + $effect(() => { + this.validationStatus; + untrack(() => { + if (this.validationStatus !== false) { + this.onInvalid.current?.( + this.validationStatus.reason, + this.validationStatus.message + ); + } + }); + }); } setName = (name: string) => { @@ -337,17 +357,39 @@ export class DateFieldRootState { this.segmentValues = Object.fromEntries(dateValues); } - isInvalid = $derived.by(() => { + validationStatus = $derived.by(() => { const value = this.value.current; - if (!value) return false; - if (this.isDateInvalid.current?.(value)) return true; + if (!value) return false as const; + + const msg = this.validate.current?.(value); + + if (msg) { + return { + reason: "custom", + message: msg, + } as const; + } + const minValue = this.minValue.current; - if (minValue && isBefore(value, minValue)) return true; + if (minValue && isBefore(value, minValue)) { + return { + reason: "min", + } as const; + } const maxValue = this.maxValue.current; - if (maxValue && isBefore(maxValue, value)) return true; + if (maxValue && isBefore(maxValue, value)) { + return { + reason: "max", + } as const; + } return false; }); + isInvalid = $derived.by(() => { + if (this.validationStatus === false) return false; + return true; + }); + inferredGranularity = $derived.by(() => { const granularity = this.granularity.current; if (granularity) return granularity; diff --git a/packages/bits-ui/src/lib/bits/date-field/types.ts b/packages/bits-ui/src/lib/bits/date-field/types.ts index 9baf95707..d430f7469 100644 --- a/packages/bits-ui/src/lib/bits/date-field/types.ts +++ b/packages/bits-ui/src/lib/bits/date-field/types.ts @@ -4,7 +4,12 @@ import type { SegmentPart, WithChildren } from "$lib/shared/index.js"; import type { OnChangeFn, WithChild, Without } from "$lib/internal/types.js"; import type { PrimitiveDivAttributes, PrimitiveSpanAttributes } from "$lib/shared/attributes.js"; import type { EditableSegmentPart } from "$lib/shared/date/field/types.js"; -import type { DateMatcher, Granularity } from "$lib/shared/date/types.js"; +import type { + DateMatcher, + DateOnInvalid, + DateValidator, + Granularity, +} from "$lib/shared/date/types.js"; export type DateFieldRootPropsWithoutHTML = WithChildren<{ /** @@ -34,23 +39,28 @@ export type DateFieldRootPropsWithoutHTML = WithChildren<{ onPlaceholderChange?: OnChangeFn; /** - * A function that returns true if the given date is invalid. This will mark - * the field as invalid and you will be responsible for displaying an error message - * to the user to inform them of the invalid state. + * A function that returns a string or array of strings as validation errors if the date is + * invalid, or nothing if the date is valid */ - isDateInvalid?: DateMatcher; + validate?: DateValidator; + + /** + * A callback fired when the date field's value is invalid. Use this to display an error + * message to the user. + */ + onInvalid?: DateOnInvalid; /** * The minimum acceptable date. When provided, the date field * will be marked as invalid if the user enters a date before this date. */ - minValue?: DateValue | undefined; + minValue?: DateValue; /** * The maximum acceptable date. When provided, the date field * will be marked as invalid if the user enters a date after this date. */ - maxValue?: DateValue | undefined; + maxValue?: DateValue; /** * If true, the date field will be disabled and users will not be able diff --git a/packages/bits-ui/src/lib/bits/date-picker/components/date-picker.svelte b/packages/bits-ui/src/lib/bits/date-picker/components/date-picker.svelte index a4767804b..67d048576 100644 --- a/packages/bits-ui/src/lib/bits/date-picker/components/date-picker.svelte +++ b/packages/bits-ui/src/lib/bits/date-picker/components/date-picker.svelte @@ -18,6 +18,8 @@ placeholder = $bindable(), onPlaceholderChange = noop, isDateUnavailable = () => false, + validate = noop, + onInvalid = noop, minValue, maxValue, disabled = false, @@ -131,16 +133,13 @@ open: pickerRootState.props.open, }); - function isUnavailableOrDisabled(date: DateValue) { - return isDateDisabled(date) || isDateUnavailable(date); - } - useDateFieldRoot({ value: pickerRootState.props.value, disabled: pickerRootState.props.disabled, readonly: pickerRootState.props.readonly, readonlySegments: pickerRootState.props.readonlySegments, - isDateInvalid: box.with(() => isUnavailableOrDisabled), + validate: box.with(() => validate), + onInvalid: box.with(() => onInvalid), minValue: pickerRootState.props.minValue, maxValue: pickerRootState.props.maxValue, granularity: pickerRootState.props.granularity, diff --git a/packages/bits-ui/src/lib/bits/date-picker/types.ts b/packages/bits-ui/src/lib/bits/date-picker/types.ts index 2c4266547..4aaef4a78 100644 --- a/packages/bits-ui/src/lib/bits/date-picker/types.ts +++ b/packages/bits-ui/src/lib/bits/date-picker/types.ts @@ -2,7 +2,13 @@ import type { DateValue } from "@internationalized/date"; import type { OnChangeFn, WithChild, WithChildren, Without } from "$lib/internal/types.js"; import type { PrimitiveDivAttributes } from "$lib/shared/attributes.js"; import type { EditableSegmentPart } from "$lib/shared/date/field/types.js"; -import type { DateMatcher, Granularity, WeekStartsOn } from "$lib/shared/date/types.js"; +import type { + DateMatcher, + DateOnInvalid, + DateValidator, + Granularity, + WeekStartsOn, +} from "$lib/shared/date/types.js"; import type { CalendarRootSnippetProps } from "$lib/types.js"; export type DatePickerRootPropsWithoutHTML = WithChildren<{ @@ -54,6 +60,18 @@ export type DatePickerRootPropsWithoutHTML = WithChildren<{ */ isDateDisabled?: DateMatcher; + /** + * A function that returns a string or array of strings as validation errors if the date is + * invalid, or nothing if the date is valid + */ + validate?: DateValidator; + + /** + * A callback fired when the date field's value is invalid. Use this to display an error + * message to the user. + */ + onInvalid?: DateOnInvalid; + /** * The minimum acceptable date. When provided, the date field * will be marked as invalid if the user enters a date before this date. diff --git a/packages/bits-ui/src/lib/bits/date-range-field/components/date-range-field.svelte b/packages/bits-ui/src/lib/bits/date-range-field/components/date-range-field.svelte index c7abacf87..d70ff08ef 100644 --- a/packages/bits-ui/src/lib/bits/date-range-field/components/date-range-field.svelte +++ b/packages/bits-ui/src/lib/bits/date-range-field/components/date-range-field.svelte @@ -23,7 +23,8 @@ granularity, locale = "en-US", hideTimeZone = false, - isDateInvalid, + validate = noop, + onInvalid = noop, maxValue, minValue, readonlySegments = [], @@ -75,7 +76,7 @@ granularity: box.with(() => granularity), locale: box.with(() => locale), hideTimeZone: box.with(() => hideTimeZone), - isDateInvalid: box.with(() => isDateInvalid), + validate: box.with(() => validate), maxValue: box.with(() => maxValue), minValue: box.with(() => minValue), placeholder: box.with( @@ -115,6 +116,7 @@ onEndValueChange(v); } ), + onInvalid: box.with(() => onInvalid), }); const mergedProps = $derived(mergeProps(restProps, rootState.props)); diff --git a/packages/bits-ui/src/lib/bits/date-range-field/date-range-field.svelte.ts b/packages/bits-ui/src/lib/bits/date-range-field/date-range-field.svelte.ts index 0374d4c24..9ad329932 100644 --- a/packages/bits-ui/src/lib/bits/date-range-field/date-range-field.svelte.ts +++ b/packages/bits-ui/src/lib/bits/date-range-field/date-range-field.svelte.ts @@ -7,7 +7,12 @@ import type { ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box import { useId } from "$lib/internal/useId.js"; import { removeDescriptionElement } from "$lib/shared/date/field/helpers.js"; import { type Formatter, createFormatter } from "$lib/shared/date/formatter.js"; -import type { DateMatcher, Granularity } from "$lib/shared/date/types.js"; +import type { + DateMatcher, + DateOnInvalid, + DateValidator, + Granularity, +} from "$lib/shared/date/types.js"; import type { DateRange, SegmentPart } from "$lib/shared/index.js"; import type { WithRefProps } from "$lib/internal/types.js"; import { useRefById } from "$lib/internal/useRefById.svelte.js"; @@ -28,7 +33,8 @@ type DateRangeFieldRootStateProps = WithRefProps< }> & ReadableBoxedValues<{ readonlySegments: SegmentPart[]; - isDateInvalid: DateMatcher | undefined; + validate: DateValidator | undefined; + onInvalid: DateOnInvalid | undefined; minValue: DateValue | undefined; maxValue: DateValue | undefined; disabled: boolean; @@ -47,7 +53,7 @@ export class DateRangeFieldRootState { value: DateRangeFieldRootStateProps["value"]; placeholder: DateRangeFieldRootStateProps["placeholder"]; readonlySegments: DateRangeFieldRootStateProps["readonlySegments"]; - isDateInvalid: DateRangeFieldRootStateProps["isDateInvalid"]; + validate: DateRangeFieldRootStateProps["validate"]; minValue: DateRangeFieldRootStateProps["minValue"]; maxValue: DateRangeFieldRootStateProps["maxValue"]; disabled: DateRangeFieldRootStateProps["disabled"]; @@ -59,6 +65,7 @@ export class DateRangeFieldRootState { required: DateRangeFieldRootStateProps["required"]; startValue: DateRangeFieldRootStateProps["startValue"]; endValue: DateRangeFieldRootStateProps["endValue"]; + onInvalid: DateRangeFieldRootStateProps["onInvalid"]; startFieldState: DateFieldRootState | undefined = undefined; endFieldState: DateFieldRootState | undefined = undefined; descriptionId = useId(); @@ -89,7 +96,8 @@ export class DateRangeFieldRootState { this.startValue = props.startValue; this.endValue = props.endValue; this.placeholder = props.placeholder; - this.isDateInvalid = props.isDateInvalid; + this.validate = props.validate; + this.onInvalid = props.onInvalid; this.minValue = props.minValue; this.maxValue = props.maxValue; this.disabled = props.disabled; @@ -197,7 +205,7 @@ export class DateRangeFieldRootState { disabled: this.disabled, readonly: this.readonly, readonlySegments: this.readonlySegments, - isDateInvalid: this.isDateInvalid, + validate: this.validate, minValue: this.minValue, maxValue: this.maxValue, hourCycle: this.hourCycle, @@ -206,6 +214,7 @@ export class DateRangeFieldRootState { required: this.required, granularity: this.granularity, placeholder: this.placeholder, + onInvalid: this.onInvalid, }, this ); diff --git a/packages/bits-ui/src/lib/bits/date-range-field/types.ts b/packages/bits-ui/src/lib/bits/date-range-field/types.ts index 26cbe2fec..85b9b02a6 100644 --- a/packages/bits-ui/src/lib/bits/date-range-field/types.ts +++ b/packages/bits-ui/src/lib/bits/date-range-field/types.ts @@ -1,7 +1,12 @@ import type { DateValue } from "@internationalized/date"; import type { OnChangeFn, WithChild, Without } from "$lib/internal/types.js"; import type { PrimitiveDivAttributes, PrimitiveSpanAttributes } from "$lib/shared/attributes.js"; -import type { DateMatcher, Granularity } from "$lib/shared/date/types.js"; +import type { + DateMatcher, + DateOnInvalid, + DateValidator, + Granularity, +} from "$lib/shared/date/types.js"; import type { DateRange, EditableSegmentPart, SegmentPart } from "$lib/shared/index.js"; import type { DateFieldSegmentProps, DateFieldSegmentPropsWithoutHTML } from "$lib/types.js"; @@ -32,10 +37,16 @@ export type DateRangeFieldRootPropsWithoutHTML = WithChild<{ onPlaceholderChange?: OnChangeFn; /** - * A function that returns true if the given date is unavailable, - * where if selected, the date field will be marked as invalid. + * A function that returns a string or array of strings as validation errors if the date is + * invalid, or nothing if the date is valid */ - isDateInvalid?: DateMatcher; + validate?: DateValidator; + + /** + * A callback fired when the date field's value is invalid. Use this to display an error + * message to the user. + */ + onInvalid?: DateOnInvalid; /** * The minimum acceptable date. When provided, the date field diff --git a/packages/bits-ui/src/lib/bits/date-range-picker/components/date-range-picker.svelte b/packages/bits-ui/src/lib/bits/date-range-picker/components/date-range-picker.svelte index 835664731..4dadf69fc 100644 --- a/packages/bits-ui/src/lib/bits/date-range-picker/components/date-range-picker.svelte +++ b/packages/bits-ui/src/lib/bits/date-range-picker/components/date-range-picker.svelte @@ -22,6 +22,7 @@ placeholder = $bindable(), onPlaceholderChange = noop, isDateUnavailable = () => false, + onInvalid = noop, minValue, maxValue, disabled = false, @@ -47,6 +48,7 @@ controlledValue = false, controlledPlaceholder = false, controlledOpen = false, + validate = noop, child, children, ...restProps @@ -161,16 +163,12 @@ open: pickerRootState.props.open, }); - function isUnavailableOrDisabled(date: DateValue) { - return isDateDisabled(date) || isDateUnavailable(date); - } - const fieldRootState = useDateRangeFieldRoot({ value: pickerRootState.props.value, disabled: pickerRootState.props.disabled, readonly: pickerRootState.props.readonly, readonlySegments: pickerRootState.props.readonlySegments, - isDateInvalid: box.with(() => isUnavailableOrDisabled), + validate: box.with(() => validate), minValue: pickerRootState.props.minValue, maxValue: pickerRootState.props.maxValue, granularity: pickerRootState.props.granularity, @@ -186,6 +184,7 @@ ), startValue: pickerRootState.props.startValue, endValue: pickerRootState.props.endValue, + onInvalid: box.with(() => onInvalid), }); const mergedProps = $derived(mergeProps(restProps, fieldRootState.props)); diff --git a/packages/bits-ui/src/lib/bits/date-range-picker/types.ts b/packages/bits-ui/src/lib/bits/date-range-picker/types.ts index 70f624d58..34c298ae6 100644 --- a/packages/bits-ui/src/lib/bits/date-range-picker/types.ts +++ b/packages/bits-ui/src/lib/bits/date-range-picker/types.ts @@ -2,7 +2,13 @@ import type { DateValue } from "@internationalized/date"; import type { OnChangeFn, WithChild, Without } from "$lib/internal/types.js"; import type { PrimitiveDivAttributes } from "$lib/shared/attributes.js"; import type { EditableSegmentPart } from "$lib/shared/date/field/types.js"; -import type { DateMatcher, Granularity, WeekStartsOn } from "$lib/shared/date/types.js"; +import type { + DateMatcher, + DateOnInvalid, + DateValidator, + Granularity, + WeekStartsOn, +} from "$lib/shared/date/types.js"; import type { DateRange } from "$lib/shared/index.js"; import type { CalendarRootSnippetProps } from "$lib/types.js"; @@ -55,17 +61,29 @@ export type DateRangePickerRootPropsWithoutHTML = WithChild<{ */ isDateDisabled?: DateMatcher; + /** + * A function that returns a string or array of strings as validation errors if the date is + * invalid, or nothing if the date is valid + */ + validate?: DateValidator; + + /** + * A callback fired when the date field's value is invalid. Use this to display an error + * message to the user. + */ + onInvalid?: DateOnInvalid; + /** * The minimum acceptable date. When provided, the date field * will be marked as invalid if the user enters a date before this date. */ - minValue?: DateValue | undefined; + minValue?: DateValue; /** * The maximum acceptable date. When provided, the date field * will be marked as invalid if the user enters a date after this date. */ - maxValue?: DateValue | undefined; + maxValue?: DateValue; /** * If true, the date field will be disabled and users will not be able diff --git a/packages/bits-ui/src/lib/shared/date/types.ts b/packages/bits-ui/src/lib/shared/date/types.ts index 3d1581611..1f2e1503b 100644 --- a/packages/bits-ui/src/lib/shared/date/types.ts +++ b/packages/bits-ui/src/lib/shared/date/types.ts @@ -5,6 +5,19 @@ export type HourCycle = 12 | 24; export type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6; export type DateMatcher = (date: DateValue) => boolean; + +/** + * A function that returns a string or array of strings as validation errors if the date is + * invalid, or nothing if the date is valid + */ +export type DateValidator = (date: DateValue) => string[] | string | void; + +/** + * A callback fired when the date field's value is invalid. Use this to display an error + * message to the user. + */ +export type DateOnInvalid = (reason: "min" | "max" | "custom", msg?: string | string[]) => void; + export type DateRange = { start: DateValue | undefined; end: DateValue | undefined; diff --git a/packages/bits-ui/src/tests/date-field/date-field.test.ts b/packages/bits-ui/src/tests/date-field/date-field.test.ts index fb66e5385..d105885e8 100644 --- a/packages/bits-ui/src/tests/date-field/date-field.test.ts +++ b/packages/bits-ui/src/tests/date-field/date-field.test.ts @@ -310,7 +310,7 @@ describe("date field", () => { it("should marks the field as invalid if the value is invalid", async () => { const { getByTestId, day, month, year, input, label, user } = setup({ granularity: "second", - isDateInvalid: (date) => date.day === 19, + validate: (date) => (date.day === 19 ? "Invalid date" : undefined), value: zonedDateTime, }); diff --git a/sites/docs/src/lib/components/search.svelte b/sites/docs/src/lib/components/search.svelte index 0b2582ac5..47ef047bd 100644 --- a/sites/docs/src/lib/components/search.svelte +++ b/sites/docs/src/lib/components/search.svelte @@ -77,7 +77,15 @@ class="focus-override inline-flex h-input w-[296px] truncate rounded-xl bg-background px-4 text-sm transition-colors placeholder:text-foreground-alt/50 focus:outline-none focus:ring-0" placeholder="Search for something..." /> - {#if searchQuery !== ""} + {#if searchQuery !== "" && results.length === 0} + No results found. + {/if} + + {#if searchQuery !== "" && results.length > 0} @@ -85,10 +93,7 @@ {#if searchState === "loading"} Loading... {/if} - No results found. + {#each results as { title, href }} ({ title: "Empty", description: "A component to display when no results are found.", props: { + forceMount: createBooleanProp({ + default: C.FALSE, + description: + "Whether or not to forcefully mount the empty state, regardless of the internal filtering logic. Useful when you want to handle filtering yourself.", + }), ...withChildProps({ elType: "HTMLDivElement" }), }, dataAttributes: [ diff --git a/sites/docs/src/lib/content/api-reference/date-field.api.ts b/sites/docs/src/lib/content/api-reference/date-field.api.ts index 22ba2172d..936c03a31 100644 --- a/sites/docs/src/lib/content/api-reference/date-field.api.ts +++ b/sites/docs/src/lib/content/api-reference/date-field.api.ts @@ -23,6 +23,8 @@ import { DateFieldInputChildSnippetprops, DateFieldInputChildrenSnippetProps, DateMatcherProp, + DateOnInvalidProp, + DateValidateProp, GranularityProp, HourCycleProp, OnDateValueChangeProp, @@ -62,10 +64,14 @@ export const root = createApiSchema({ description: "Whether or not the date field is required.", default: C.FALSE, }), - isDateInvalid: createFunctionProp({ - definition: DateMatcherProp, + validate: createFunctionProp({ + definition: DateValidateProp, description: "A function that returns whether or not a date is unavailable.", }), + onInvalid: createFunctionProp({ + definition: DateOnInvalidProp, + description: "A callback fired when the date field's value is invalid.", + }), hourCycle: createEnumProp({ options: ["12", "24"], description: diff --git a/sites/docs/src/lib/content/api-reference/date-picker.api.ts b/sites/docs/src/lib/content/api-reference/date-picker.api.ts index 4d4b3852c..56118bd66 100644 --- a/sites/docs/src/lib/content/api-reference/date-picker.api.ts +++ b/sites/docs/src/lib/content/api-reference/date-picker.api.ts @@ -61,6 +61,8 @@ export const root = createApiSchema({ controlledPlaceholder: controlledPlaceholderProp, isDateUnavailable: calendarRoot.props!.isDateUnavailable, isDateDisabled: calendarRoot.props!.isDateDisabled, + validate: dateFieldRoot.props!.validate, + onInvalid: dateFieldRoot.props!.onInvalid, required: dateFieldRoot.props!.required, readonlySegments: dateFieldRoot.props!.readonlySegments, disableDaysOutsideMonth: calendarRoot.props!.disableDaysOutsideMonth, diff --git a/sites/docs/src/lib/content/api-reference/date-range-field.api.ts b/sites/docs/src/lib/content/api-reference/date-range-field.api.ts index 619656af4..03f21b908 100644 --- a/sites/docs/src/lib/content/api-reference/date-range-field.api.ts +++ b/sites/docs/src/lib/content/api-reference/date-range-field.api.ts @@ -42,7 +42,8 @@ export const root = createApiSchema({ placeholder: dateFieldRoot.props!.placeholder, onPlaceholderChange: dateFieldRoot.props!.onPlaceholderChange, controlledPlaceholder: dateFieldRoot.props!.controlledPlaceholder, - isDateInvalid: dateFieldRoot.props!.isDateInvalid, + validate: dateFieldRoot.props!.validate, + onInvalid: dateFieldRoot.props!.onInvalid, minValue: dateFieldRoot.props!.minValue, maxValue: dateFieldRoot.props!.maxValue, granularity: dateFieldRoot.props!.granularity, diff --git a/sites/docs/src/lib/content/api-reference/date-range-picker.api.ts b/sites/docs/src/lib/content/api-reference/date-range-picker.api.ts index 7f7fa0175..e663717af 100644 --- a/sites/docs/src/lib/content/api-reference/date-range-picker.api.ts +++ b/sites/docs/src/lib/content/api-reference/date-range-picker.api.ts @@ -45,6 +45,8 @@ const root = createApiSchema({ isDateUnavailable: calendarRoot.props!.isDateUnavailable, minValue: rangeFieldRoot.props!.minValue, maxValue: rangeFieldRoot.props!.maxValue, + validate: rangeFieldRoot.props!.validate, + onInvalid: rangeFieldRoot.props!.onInvalid, granularity: rangeFieldRoot.props!.granularity, hideTimeZone: rangeFieldRoot.props!.hideTimeZone, hourCycle: rangeFieldRoot.props!.hourCycle, diff --git a/sites/docs/src/lib/content/api-reference/extended-types/shared/date-on-invalid-prop.md b/sites/docs/src/lib/content/api-reference/extended-types/shared/date-on-invalid-prop.md new file mode 100644 index 000000000..4ea75222f --- /dev/null +++ b/sites/docs/src/lib/content/api-reference/extended-types/shared/date-on-invalid-prop.md @@ -0,0 +1,3 @@ +```ts +(reason: "min" | "max" | "custom", msg?: string | string[]) => void; +``` diff --git a/sites/docs/src/lib/content/api-reference/extended-types/shared/date-validate-prop.md b/sites/docs/src/lib/content/api-reference/extended-types/shared/date-validate-prop.md new file mode 100644 index 000000000..ea69c927d --- /dev/null +++ b/sites/docs/src/lib/content/api-reference/extended-types/shared/date-validate-prop.md @@ -0,0 +1,3 @@ +```ts +(date: DateValue) => string[] | string | void; +``` diff --git a/sites/docs/src/lib/content/api-reference/extended-types/shared/index.ts b/sites/docs/src/lib/content/api-reference/extended-types/shared/index.ts index 77fbaa115..8710eb9a1 100644 --- a/sites/docs/src/lib/content/api-reference/extended-types/shared/index.ts +++ b/sites/docs/src/lib/content/api-reference/extended-types/shared/index.ts @@ -11,6 +11,8 @@ export { default as DateOnPlaceholderChangeProp } from "./date-on-placeholder-ch export { default as DateOnRangeChangeProp } from "./date-on-range-change-prop.md"; export { default as DateRangeProp } from "./date-range-prop.md"; export { default as DateValueProp } from "./date-value-prop.md"; +export { default as DateOnInvalidProp } from "./date-on-invalid-prop.md"; +export { default as DateValidateProp } from "./date-validate-prop.md"; export { default as DirProp } from "./dir-prop.md"; export { default as EscapeKeydownBehaviorProp } from "./escape-keydown-behavior-prop.md"; export { default as GranularityProp } from "./granularity-prop.md";