Skip to content

Commit d75f5e3

Browse files
J-Sekjohnleider
andauthored
feat(VDateInput): sync with placeholder, infer from locale (#21409)
fixes #21397 Co-authored-by: John Leider <[email protected]>
1 parent c6dc1ca commit d75f5e3

File tree

4 files changed

+235
-83
lines changed

4 files changed

+235
-83
lines changed

packages/api-generator/src/locale/en/VDateInput.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"props": {
33
"hideActions": "Hide the Cancel and OK buttons, and automatically update the value when a date is selected.",
44
"displayFormat": "The format of the date that is displayed in the input. Can use any format [here](/features/dates/#format-options) or a custom function.",
5-
"inputFormat": "Format for manual date input. Use yyyy, mm, dd with separators '.', '-', '/' (e.g. 'yyyy-mm-dd', 'dd/mm/yyyy') or a custom function.",
5+
"inputFormat": "Format for manual date input. Use yyyy, mm, dd with separators '.', '-', '/' (e.g. 'yyyy-mm-dd', 'dd/mm/yyyy').",
66
"location": "Specifies the date picker's location. Can combine by using a space separated string.",
77
"updateOn": "Specifies when the text input should update the model value. If empty, the text field will go into read-only state."
88
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Composables
2+
import { useDate } from '@/composables/date/date'
3+
4+
// Utilities
5+
import { toRef } from 'vue'
6+
import { consoleWarn, propsFactory } from '@/util'
7+
8+
// Types
9+
import type { Ref } from 'vue'
10+
11+
// Types
12+
export interface DateFormatProps {
13+
inputFormat?: string
14+
}
15+
16+
class DateFormatSpec {
17+
constructor (
18+
public readonly order: string, // mdy | dmy | ymd
19+
public readonly separator: string // / | - | .
20+
) { }
21+
22+
get format () {
23+
return this.order.split('')
24+
.map(sign => `${sign}${sign}`)
25+
.join(this.separator)
26+
.replace('yy', 'yyyy')
27+
}
28+
29+
static canBeParsed (v: any) {
30+
if (typeof v !== 'string') return false
31+
const lowercase = v.toLowerCase()
32+
return ['y', 'm', 'd'].every(sign => lowercase.includes(sign)) &&
33+
['/', '-', '.'].some(sign => v.includes(sign))
34+
}
35+
36+
static parse (v: string) {
37+
if (!DateFormatSpec.canBeParsed(v)) {
38+
throw new Error(`[${v}] cannot be parsed into date format specification`)
39+
}
40+
const order = v.toLowerCase().split('')
41+
.filter((c, i, all) => 'dmy'.includes(c) && all.indexOf(c) === i)
42+
.join('')
43+
const separator = ['/', '-', '.'].find(sign => v.includes(sign))!
44+
return new DateFormatSpec(order, separator)
45+
}
46+
}
47+
48+
export const makeDateFormatProps = propsFactory({
49+
inputFormat: {
50+
type: String,
51+
validator: (v: string) => !v || DateFormatSpec.canBeParsed(v),
52+
},
53+
}, 'date-format')
54+
55+
export function useDateFormat (props: DateFormatProps, locale: Ref<string>) {
56+
const adapter = useDate()
57+
58+
function inferFromLocale () {
59+
const localeForDateFormat = locale.value ?? 'en-US'
60+
const formatFromLocale = Intl.DateTimeFormat(localeForDateFormat, { year: 'numeric', month: '2-digit', day: '2-digit' })
61+
.format(adapter.toJsDate(adapter.parseISO('1999-12-07')))
62+
.replace(/(07)|(٠٧)|(٢٩)|(۱۶)|()/, 'dd')
63+
.replace(/(12)|(١٢)|(٠٨)|(۰۹)|()/, 'mm')
64+
.replace(/(1999)|(2542)|(١٩٩٩)|(١٤٢٠)|(۱۳۷۸)|()/, 'yyyy')
65+
.replace(/[^ymd\-/.]/g, '')
66+
.replace(/\.$/, '')
67+
68+
if (!DateFormatSpec.canBeParsed(formatFromLocale)) {
69+
consoleWarn(`Date format inferred from locale [${localeForDateFormat}] is invalid: [${formatFromLocale}]`)
70+
return 'mm/dd/yyyy'
71+
}
72+
73+
return formatFromLocale
74+
}
75+
76+
const currentFormat = toRef(() => {
77+
return DateFormatSpec.canBeParsed(props.inputFormat)
78+
? DateFormatSpec.parse(props.inputFormat!)
79+
: DateFormatSpec.parse(inferFromLocale())
80+
})
81+
82+
function parseDate (dateString: string) {
83+
function parseDateParts (text: string): Record<'y' |'m' | 'd', number> {
84+
const parts = text.trim().split(currentFormat.value.separator)
85+
86+
return {
87+
y: Number(parts[currentFormat.value.order.indexOf('y')]),
88+
m: Number(parts[currentFormat.value.order.indexOf('m')]),
89+
d: Number(parts[currentFormat.value.order.indexOf('d')]),
90+
}
91+
}
92+
93+
function validateDateParts (dateParts: Record<string, number>) {
94+
const { y: year, m: month, d: day } = dateParts
95+
if (!year || !month || !day) return null
96+
if (month < 1 || month > 12) return null
97+
if (day < 1 || day > 31) return null
98+
99+
return { year: autoFixYear(year), month, day }
100+
}
101+
102+
function autoFixYear (year: number) {
103+
const currentYear = adapter.getYear(adapter.date())
104+
if (year > 100 || currentYear % 100 >= 50) {
105+
return year
106+
}
107+
108+
const currentCentury = ~~(currentYear / 100) * 100
109+
110+
return year < 50
111+
? currentCentury + year
112+
: (currentCentury - 100) + year
113+
}
114+
115+
const dateParts = parseDateParts(dateString)
116+
const validatedParts = validateDateParts(dateParts)
117+
118+
if (!validatedParts) return null
119+
120+
const { year, month, day } = validatedParts
121+
122+
const pad = (v: number) => String(v).padStart(2, '0')
123+
124+
return adapter.parseISO(`${year}-${pad(month)}-${pad(day)}`)
125+
}
126+
127+
function isValid (text: string) {
128+
return !!parseDate(text)
129+
}
130+
131+
function formatDate (value: unknown) {
132+
const parts = adapter.toISO(value).split('-')
133+
134+
return currentFormat.value.order.split('')
135+
.map(sign => parts['ymd'.indexOf(sign)])
136+
.join(currentFormat.value.separator)
137+
}
138+
139+
return {
140+
isValid,
141+
parseDate,
142+
formatDate,
143+
parserFormat: toRef(() => currentFormat.value.format),
144+
}
145+
}

packages/vuetify/src/labs/VDateInput/VDateInput.tsx

Lines changed: 33 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { makeVTextFieldProps, VTextField } from '@/components/VTextField/VTextFi
66

77
// Composables
88
import { useDate } from '@/composables/date'
9+
import { makeDateFormatProps, useDateFormat } from '@/composables/dateFormat'
910
import { makeDisplayProps, useDisplay } from '@/composables/display'
1011
import { makeFocusProps, useFocus } from '@/composables/focus'
1112
import { forwardRefs } from '@/composables/forwardRefs'
@@ -14,7 +15,7 @@ import { useProxiedModel } from '@/composables/proxiedModel'
1415

1516
// Utilities
1617
import { computed, ref, shallowRef, watch } from 'vue'
17-
import { genericComponent, omit, propsFactory, useRender, wrapInArray } from '@/util'
18+
import { createRange, genericComponent, omit, propsFactory, useRender, wrapInArray } from '@/util'
1819

1920
// Types
2021
import type { PropType } from 'vue'
@@ -35,7 +36,6 @@ export type VDateInputSlots = Omit<VTextFieldSlots, 'default'> & {
3536

3637
export const makeVDateInputProps = propsFactory({
3738
displayFormat: [Function, String],
38-
inputFormat: [Function, String],
3939
location: {
4040
type: String as PropType<StrategyProps['location']>,
4141
default: 'bottom start',
@@ -46,6 +46,7 @@ export const makeVDateInputProps = propsFactory({
4646
default: () => ['blur', 'enter'],
4747
},
4848

49+
...makeDateFormatProps(),
4950
...makeDisplayProps({
5051
mobile: null,
5152
}),
@@ -54,7 +55,6 @@ export const makeVDateInputProps = propsFactory({
5455
hideActions: true,
5556
}),
5657
...makeVTextFieldProps({
57-
placeholder: 'mm/dd/yyyy',
5858
prependIcon: '$calendar',
5959
}),
6060
...omit(makeVDatePickerProps({
@@ -76,8 +76,9 @@ export const VDateInput = genericComponent<VDateInputSlots>()({
7676
},
7777

7878
setup (props, { emit, slots }) {
79-
const { t } = useLocale()
79+
const { t, current: currentLocale } = useLocale()
8080
const adapter = useDate()
81+
const { isValid, parseDate, formatDate, parserFormat } = useDateFormat(props, currentLocale)
8182
const { mobile } = useDisplay(props)
8283
const { isFocused, focus, blur } = useFocus(props)
8384

@@ -100,77 +101,10 @@ export const VDateInput = genericComponent<VDateInputSlots>()({
100101
if (typeof props.displayFormat === 'function') {
101102
return props.displayFormat(date)
102103
}
103-
104-
return adapter.format(date, props.displayFormat ?? 'keyboardDate')
105-
}
106-
107-
function parseDateString (dateString: string, format: string) {
108-
function countConsecutiveChars (str: string, startIndex: number): number {
109-
const char = str[startIndex]
110-
let count = 0
111-
while (str[startIndex + count] === char) count++
112-
return count
104+
if (props.displayFormat) {
105+
return adapter.format(date, props.displayFormat ?? 'keyboardDate')
113106
}
114-
115-
function parseDateParts (dateString: string, format: string) {
116-
const dateParts: Record<string, number> = {}
117-
let stringIndex = 0
118-
const upperFormat = format.toUpperCase()
119-
120-
for (let formatIndex = 0; formatIndex < upperFormat.length;) {
121-
const formatChar = upperFormat[formatIndex]
122-
const charCount = countConsecutiveChars(upperFormat, formatIndex)
123-
const dateValue = dateString.slice(stringIndex, stringIndex + charCount)
124-
125-
if (['Y', 'M', 'D'].includes(formatChar)) {
126-
const numValue = parseInt(dateValue)
127-
if (isNaN(numValue)) return null
128-
dateParts[formatChar] = numValue
129-
}
130-
131-
formatIndex += charCount
132-
stringIndex += charCount
133-
}
134-
135-
return dateParts
136-
}
137-
138-
function validateDateParts (dateParts: Record<string, number>) {
139-
const { Y: year, M: month, D: day } = dateParts
140-
if (!year || !month || !day) return null
141-
if (month < 1 || month > 12) return null
142-
if (day < 1 || day > 31) return null
143-
return { year, month, day }
144-
}
145-
146-
const dateParts = parseDateParts(dateString, format)
147-
if (!dateParts) return null
148-
149-
const validatedParts = validateDateParts(dateParts)
150-
if (!validatedParts) return null
151-
152-
const { year, month, day } = validatedParts
153-
154-
return { year, month, day }
155-
}
156-
157-
function parseUserInput (value: string) {
158-
if (typeof props.inputFormat === 'function') {
159-
return props.inputFormat(value)
160-
}
161-
162-
if (typeof props.inputFormat === 'string') {
163-
const formattedDate = parseDateString(value, props.inputFormat)
164-
165-
if (!formattedDate) {
166-
return model.value
167-
}
168-
169-
const { year, month, day } = formattedDate
170-
return adapter.parseISO(`${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`)
171-
}
172-
173-
return adapter.isValid(value) ? adapter.date(value) : model.value
107+
return formatDate(date)
174108
}
175109

176110
const display = computed(() => {
@@ -271,13 +205,35 @@ export const VDateInput = genericComponent<VDateInputSlots>()({
271205
}
272206

273207
function onUserInput ({ value }: HTMLInputElement) {
274-
model.value = !value ? emptyModelValue() : parseUserInput(value)
208+
if (!value.trim()) {
209+
model.value = emptyModelValue()
210+
} else if (!props.multiple) {
211+
if (isValid(value)) {
212+
model.value = parseDate(value)
213+
}
214+
} else {
215+
const parts = value.trim().split(/\D+-\D+|[^\d\-/.]+/)
216+
if (parts.every(isValid)) {
217+
if (props.multiple === 'range') {
218+
model.value = getRange(parts)
219+
} else {
220+
model.value = parts.map(parseDate)
221+
}
222+
}
223+
}
224+
}
225+
226+
function getRange (inputDates: string[]) {
227+
const [start, stop] = inputDates.map(parseDate).toSorted((a, b) => adapter.isAfter(a, b) ? 1 : -1)
228+
const diff = adapter.getDiff(stop ?? start, start, 'days')
229+
return [start, ...createRange(diff, 1)
230+
.map(i => adapter.addDays(start, i))]
275231
}
276232

277233
useRender(() => {
278234
const confirmEditProps = VConfirmEdit.filterProps(props)
279235
const datePickerProps = VDatePicker.filterProps(omit(props, ['active', 'location', 'rounded']))
280-
const textFieldProps = VTextField.filterProps(props)
236+
const textFieldProps = VTextField.filterProps(omit(props, ['placeholder']))
281237

282238
return (
283239
<VTextField
@@ -287,6 +243,7 @@ export const VDateInput = genericComponent<VDateInputSlots>()({
287243
style={ props.style }
288244
modelValue={ display.value }
289245
inputmode={ inputmode.value }
246+
placeholder={ props.placeholder ?? parserFormat.value }
290247
readonly={ isReadonly.value }
291248
onKeydown={ isInteractive.value ? onKeydown : undefined }
292249
focused={ menu.value || isFocused.value }

0 commit comments

Comments
 (0)