diff --git a/packages/oruga/src/components/datepicker/Datepicker.vue b/packages/oruga/src/components/datepicker/Datepicker.vue index 9b68f7991..a3e2351af 100644 --- a/packages/oruga/src/components/datepicker/Datepicker.vue +++ b/packages/oruga/src/components/datepicker/Datepicker.vue @@ -174,7 +174,8 @@ const emits = defineEmits<{ (e: "icon-right-click", event: Event): void; }>(); -const { defaultDateFormatter, defaultDateParser } = useDatepickerMixins(props); +const { dtf, defaultDateFormatter, defaultDateParser } = + useDatepickerMixins(props); const { isMobile } = useMatchMedia(props.mobileBreakpoint); @@ -583,6 +584,7 @@ defineExpose({ focus: () => pickerRef.value?.focus(), value: vmodel }); :dropdown-classes="dropdownClass" :root-classes="rootClasses" :box-class="boxClassBind" + :dtf="dtf" @focus="$emit('focus', $event)" @blur="$emit('blur', $event)" @invalid="$emit('invalid', $event)" diff --git a/packages/oruga/src/components/datepicker/useDatepickerMixins.ts b/packages/oruga/src/components/datepicker/useDatepickerMixins.ts index 9a58a517f..cb23cafc4 100644 --- a/packages/oruga/src/components/datepicker/useDatepickerMixins.ts +++ b/packages/oruga/src/components/datepicker/useDatepickerMixins.ts @@ -3,6 +3,7 @@ import { matchWithGroups } from "./utils"; import type { DatepickerProps } from "./types"; import { isTrueish } from "@/utils/helpers"; +/** Time Format Feature */ export function useDatepickerMixins( props: DatepickerProps, ) { @@ -179,5 +180,10 @@ export function useDatepickerMixins( return (isArray ? dates : dates[0]) as typeof props.modelValue; }; - return { isDateSelectable, defaultDateParser, defaultDateFormatter }; + return { + dtf, + isDateSelectable, + defaultDateParser, + defaultDateFormatter, + }; } diff --git a/packages/oruga/src/components/datetimepicker/Datetimepicker.vue b/packages/oruga/src/components/datetimepicker/Datetimepicker.vue index 4dc306ded..86abe7c9f 100644 --- a/packages/oruga/src/components/datetimepicker/Datetimepicker.vue +++ b/packages/oruga/src/components/datetimepicker/Datetimepicker.vue @@ -15,7 +15,7 @@ import { getOption } from "@/utils/config"; import { isMobileAgent, pad } from "@/utils/helpers"; import { defineClasses, useInputHandler } from "@/composables"; -import { matchWithGroups } from "../datepicker/utils"; +import { useDateimepickerMixins } from "./useDatetimepickerMixin"; import type { DatepickerProps } from "../datepicker/types"; import type { TimepickerProps } from "../timepicker/types"; @@ -33,10 +33,6 @@ defineOptions({ inheritAttrs: false, }); -const AM = "AM"; -const PM = "PM"; -const HOUR_FORMAT_24 = "24"; - const props = defineProps({ /** Override existing theme classes completely */ override: { type: Boolean, default: undefined }, @@ -90,16 +86,16 @@ const props = defineProps({ /** Custom function to format a date into a string */ datetimeFormatter: { type: Function as PropType<(date: Date) => string>, - default: ( - date: Date | Date[], - defaultFunction: (date: Date | Date[]) => string, - ) => getOption("datetimepicker.dateFormatter", defaultFunction)(date), + default: (date: Date | Date[]) => + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getOption("datetimepicker.dateFormatter", (_) => undefined)(date), }, /** Custom function to parse a string into a date */ datetimeParser: { type: Function as PropType<(date: string) => Date>, - default: (date: string, defaultFunction: (date: string) => Date) => - getOption("datetimepicker.dateParser", defaultFunction)(date), + default: (date: string) => + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getOption("datetimepicker.dateParser", (_) => undefined)(date), }, /** Date creator function, default is `new Date()` */ datetimeCreator: { @@ -261,6 +257,9 @@ watch([() => isMobileNative.value, () => props.inline], () => { if (datepickerRef.value) datepickerRef.value.$forceUpdate(); }); +const { defaultDatetimeFormatter, defaultDatetimeParser } = + useDateimepickerMixins(props); + /** Dropdown active state */ const isActive = defineModel("active", { default: false }); @@ -271,10 +270,8 @@ function updateVModel(value: Date | Date[]): void { vmodel.value = undefined; return; } - if (Array.isArray(value)) { - updateVModel(value[0]); - return; - } + if (Array.isArray(value)) return updateVModel(value[0]); + let date = new Date(value.getTime()); if (!props.modelValue) { date = props.datetimeCreator(value); @@ -402,150 +399,6 @@ function formatNative(value: Date): string { return ""; } -// --- Time Format Feature --- - -const enableSeconds = computed(() => - timepickerRef.value?.enableSeconds - ? timepickerRef.value.enableSeconds - : false, -); - -const localeOptions = computed( - () => - new Intl.DateTimeFormat(props.locale, { - year: "numeric", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - second: enableSeconds.value ? "numeric" : undefined, - }).resolvedOptions() as Intl.DateTimeFormatOptions, -); - -const isHourFormat24 = computed( - () => - (timepickerProps.value?.hourFormat && - timepickerProps.value.hourFormat === HOUR_FORMAT_24) || - (!timepickerProps.value?.hourFormat && !localeOptions.value.hour12), -); - -const dtf = computed( - () => - new Intl.DateTimeFormat(props.locale, { - year: localeOptions.value.year || "numeric", - month: localeOptions.value.month || "numeric", - day: localeOptions.value.day || "numeric", - hour: localeOptions.value.hour || "numeric", - minute: localeOptions.value.minute || "numeric", - second: enableSeconds.value - ? localeOptions.value.second || "numeric" - : undefined, - hourCycle: !isHourFormat24.value ? "h12" : "h23", - }), -); - -const amString = computed(() => { - if ( - dtf.value.formatToParts && - typeof dtf.value.formatToParts === "function" - ) { - const d = props.datetimeCreator(new Date()); - d.setHours(10); - const dayPeriod = dtf.value - .formatToParts(d) - .find((part) => part.type === "dayPeriod"); - if (dayPeriod) return dayPeriod.value; - } - return AM; -}); - -const pmString = computed(() => { - if ( - dtf.value.formatToParts && - typeof dtf.value.formatToParts === "function" - ) { - const d = props.datetimeCreator(new Date()); - d.setHours(20); - const dayPeriod = dtf.value - .formatToParts(d) - .find((part) => part.type === "dayPeriod"); - if (dayPeriod) return dayPeriod.value; - } - return PM; -}); - -function defaultDatetimeParser(value: string): Date { - function defaultParser(date: string): Date { - if ( - dtf.value.formatToParts && - typeof dtf.value.formatToParts === "function" - ) { - const dayPeriods = [AM, PM, AM.toLowerCase(), PM.toLowerCase()]; - if (timepickerRef.value) { - dayPeriods.push(amString.value); - dayPeriods.push(pmString.value); - } - const parts = dtf.value.formatToParts(new Date()); - const formatRegex = parts - .map((part, idx) => { - if (part.type === "literal") { - if ( - idx + 1 < parts.length && - parts[idx + 1].type === "hour" - ) { - return `[^\\d]+`; - } - return part.value.replace(/ /g, "\\s?"); - } else if (part.type === "dayPeriod") { - return `((?!=<${part.type}>)(${dayPeriods.join( - "|", - )})?)`; - } - return `((?!=<${part.type}>)\\d+)`; - }) - .join(""); - const datetimeGroups = matchWithGroups(formatRegex, date); - - // We do a simple validation for the group. - // If it is not valid, it will fallback to Date.parse below - if ( - datetimeGroups.year && - datetimeGroups.year.length === 4 && - datetimeGroups.month && - datetimeGroups.month <= 12 && - datetimeGroups.day && - datetimeGroups.day <= 31 && - datetimeGroups.hour && - datetimeGroups.hour >= 0 && - datetimeGroups.hour < 24 && - datetimeGroups.minute && - datetimeGroups.minute >= 0 && - datetimeGroups.minute <= 59 - ) { - const d = new Date( - datetimeGroups.year, - datetimeGroups.month - 1, - datetimeGroups.day, - datetimeGroups.hour, - datetimeGroups.minute, - datetimeGroups.second || 0, - ); - return d; - } - } - - return new Date(Date.parse(date)); - } - const date = (props.datetimeParser as any)(value, defaultParser); - return date; -} - -function defaultDatetimeFormatter(date: Date): string { - return (props.datetimeFormatter as any)(date, (date: Date) => - date ? dtf.value.format(date) : "", - ); -} - // --- Event Handler --- /** Parse date from string */ diff --git a/packages/oruga/src/components/datetimepicker/types.ts b/packages/oruga/src/components/datetimepicker/types.ts new file mode 100644 index 000000000..1a79c3f70 --- /dev/null +++ b/packages/oruga/src/components/datetimepicker/types.ts @@ -0,0 +1,4 @@ +import type { ComponentProps } from "vue-component-type-helpers"; +import Datetimepicker from "./Datetimepicker.vue"; + +export type DatetimepickerProps = ComponentProps; diff --git a/packages/oruga/src/components/datetimepicker/useDatetimepickerMixin.ts b/packages/oruga/src/components/datetimepicker/useDatetimepickerMixin.ts new file mode 100644 index 000000000..0f2619528 --- /dev/null +++ b/packages/oruga/src/components/datetimepicker/useDatetimepickerMixin.ts @@ -0,0 +1,161 @@ +import { computed } from "vue"; +import { matchWithGroups } from "../datepicker/utils"; +import type { DatetimepickerProps } from "./types"; + +const AM = "AM" as const; +const PM = "PM" as const; +const HOUR_FORMAT_24 = "24" as const; + +/** Time Format Feature */ +export function useDateimepickerMixins(props: DatetimepickerProps) { + const localeOptions = computed( + () => + new Intl.DateTimeFormat(props.locale, { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: props.timepicker?.enableSeconds ? "numeric" : undefined, + }).resolvedOptions() as Intl.DateTimeFormatOptions, + ); + + const isHourFormat24 = computed( + () => + (props.timepicker?.hourFormat && + props.timepicker.hourFormat === HOUR_FORMAT_24) || + (!props.timepicker?.hourFormat && !localeOptions.value.hour12), + ); + + const dtf = computed( + () => + new Intl.DateTimeFormat(props.locale, { + year: localeOptions.value.year || "numeric", + month: localeOptions.value.month || "numeric", + day: localeOptions.value.day || "numeric", + hour: localeOptions.value.hour || "numeric", + minute: localeOptions.value.minute || "numeric", + second: props.timepicker?.enableSeconds + ? localeOptions.value.second || "numeric" + : undefined, + hourCycle: !isHourFormat24.value ? "h12" : "h23", + }), + ); + + const amString = computed(() => { + if ( + dtf.value.formatToParts && + typeof dtf.value.formatToParts === "function" + ) { + const d = props.datetimeCreator(new Date()); + d.setHours(10); + const dayPeriod = dtf.value + .formatToParts(d) + .find((part) => part.type === "dayPeriod"); + if (dayPeriod) return dayPeriod.value; + } + return AM; + }); + + const pmString = computed(() => { + if ( + dtf.value.formatToParts && + typeof dtf.value.formatToParts === "function" + ) { + const d = props.datetimeCreator(new Date()); + d.setHours(20); + const dayPeriod = dtf.value + .formatToParts(d) + .find((part) => part.type === "dayPeriod"); + if (dayPeriod) return dayPeriod.value; + } + return PM; + }); + + function defaultDatetimeParser(value: string): Date { + function defaultParser(date: string): Date { + if ( + dtf.value.formatToParts && + typeof dtf.value.formatToParts === "function" + ) { + const dayPeriods = [ + AM, + PM, + AM.toLowerCase(), + PM.toLowerCase(), + amString.value, + pmString.value, + ]; + const parts = dtf.value.formatToParts(new Date()); + const formatRegex = parts + .map((part, idx) => { + if (part.type === "literal") { + if ( + idx + 1 < parts.length && + parts[idx + 1].type === "hour" + ) { + return `[^\\d]+`; + } + return part.value.replace(/ /g, "\\s?"); + } else if (part.type === "dayPeriod") { + return `((?!=<${part.type}>)(${dayPeriods.join( + "|", + )})?)`; + } + return `((?!=<${part.type}>)\\d+)`; + }) + .join(""); + const datetimeGroups = matchWithGroups(formatRegex, date); + + // We do a simple validation for the group. + // If it is not valid, it will fallback to Date.parse below + if ( + datetimeGroups.year && + datetimeGroups.year.length === 4 && + datetimeGroups.month && + datetimeGroups.month <= 12 && + datetimeGroups.day && + datetimeGroups.day <= 31 && + datetimeGroups.hour && + datetimeGroups.hour >= 0 && + datetimeGroups.hour < 24 && + datetimeGroups.minute && + datetimeGroups.minute >= 0 && + datetimeGroups.minute <= 59 + ) { + const d = new Date( + datetimeGroups.year, + datetimeGroups.month - 1, + datetimeGroups.day, + datetimeGroups.hour, + datetimeGroups.minute, + datetimeGroups.second || 0, + ); + return d; + } + } + + return new Date(Date.parse(date)); + } + + // call prop function + const date = props.datetimeParser(value); + // call default if prop function is not given + if (typeof date === "undefined") return defaultParser(value); + else return date; + } + + function defaultDatetimeFormatter(value: Date): string { + // call prop function + const date = props.datetimeFormatter(value); + // call default if prop function is not given + if (typeof date === "undefined") return dtf.value.format(value); + else return date; + } + + return { + dtf, + defaultDatetimeFormatter, + defaultDatetimeParser, + }; +} diff --git a/packages/oruga/src/components/timepicker/Timepicker.vue b/packages/oruga/src/components/timepicker/Timepicker.vue index ce6ddce63..864e90641 100644 --- a/packages/oruga/src/components/timepicker/Timepicker.vue +++ b/packages/oruga/src/components/timepicker/Timepicker.vue @@ -256,6 +256,7 @@ defineEmits<{ const { isMobile } = useMatchMedia(props.mobileBreakpoint); const { + dtf, defaultTimeFormatter, defaultTimeParser, pmString, @@ -338,13 +339,9 @@ const hours = computed[]>(() => { value = i + 1; label = value; if (meridienSelected.value === amString.value) { - if (value === 12) { - value = 0; - } + if (value === 12) value = 0; } else if (meridienSelected.value === pmString.value) { - if (value !== 12) { - value += 12; - } + if (value !== 12) value += 12; } } hours.push({ @@ -756,6 +753,7 @@ defineExpose({ focus: () => pickerRef.value?.focus(), value: vmodel }); :dropdown-classes="dropdownClass" :root-classes="rootClasses" :box-class="boxClassBind" + :dtf="dtf" @focus="$emit('focus', $event)" @blur="$emit('blur', $event)" @invalid="$emit('invalid', $event)" diff --git a/packages/oruga/src/components/timepicker/useTimepickerMixins.ts b/packages/oruga/src/components/timepicker/useTimepickerMixins.ts index 9367f086d..bc7ef00d2 100644 --- a/packages/oruga/src/components/timepicker/useTimepickerMixins.ts +++ b/packages/oruga/src/components/timepicker/useTimepickerMixins.ts @@ -7,6 +7,7 @@ const PM = "PM" as const; const HOUR_FORMAT_24 = "24" as const; const HOUR_FORMAT_12 = "12" as const; +/** Time Format Feature */ export function useTimepickerMixins(props: TimepickerProps) { const localeOptions = computed( () => @@ -196,7 +197,10 @@ export function useTimepickerMixins(props: TimepickerProps) { const timeSplit = time.split(":"); let hours = parseInt(timeSplit[0], 10); const minutes = parseInt(timeSplit[1], 10); - const seconds = props.enableSeconds ? parseInt(timeSplit[2], 10) : 0; + const seconds = + props.enableSeconds && timeSplit.length >= 3 + ? parseInt(timeSplit[2], 10) + : 0; if ( isNaN(hours) || hours < 0 || @@ -224,6 +228,7 @@ export function useTimepickerMixins(props: TimepickerProps) { } return { + dtf, defaultTimeFormatter, defaultTimeParser, pmString, diff --git a/packages/oruga/src/components/utils/PickerWrapper.vue b/packages/oruga/src/components/utils/PickerWrapper.vue index 99a782c4a..d3c01aa78 100644 --- a/packages/oruga/src/components/utils/PickerWrapper.vue +++ b/packages/oruga/src/components/utils/PickerWrapper.vue @@ -64,6 +64,8 @@ const props = defineProps({ min: { type: Date, default: undefined }, max: { type: Date, default: undefined }, stayOpen: { type: Boolean, default: false }, + // the DateTimeFormat object to watch for to update the parsed input value + dtf: { type: Object, default: undefined }, rootClasses: { type: Array as PropType, required: true }, dropdownClasses: { type: Array as PropType, required: true }, boxClass: { type: Array as PropType, required: true }, @@ -157,6 +159,12 @@ watch( { immediate: true }, ); +// update the parsed input value when the dtf change +watch( + () => props.dtf, + () => setValue(inputValue.value), +); + /** Set the vmodel value and update the prop value */ function setValue(value: string): void { // parse to date