@instructure/ui-date-input 9.6.1-snapshot-2 → 9.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -2
- package/es/DateInput2/index.js +150 -86
- package/es/DateInput2/props.js +2 -3
- package/lib/DateInput2/index.js +150 -86
- package/lib/DateInput2/props.js +2 -3
- package/package.json +20 -20
- package/src/DateInput/README.md +1 -1
- package/src/DateInput2/README.md +183 -176
- package/src/DateInput2/index.tsx +147 -89
- package/src/DateInput2/props.ts +20 -31
- package/tsconfig.build.tsbuildinfo +1 -1
- package/types/DateInput2/index.d.ts +8 -6
- package/types/DateInput2/index.d.ts.map +1 -1
- package/types/DateInput2/props.d.ts +14 -18
- package/types/DateInput2/props.d.ts.map +1 -1
package/src/DateInput2/index.tsx
CHANGED
@@ -44,47 +44,79 @@ import type { DateInput2Props } from './props'
|
|
44
44
|
import type { FormMessage } from '@instructure/ui-form-field'
|
45
45
|
import type { Moment } from '@instructure/ui-i18n'
|
46
46
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
//
|
53
|
-
|
54
|
-
|
55
|
-
}
|
47
|
+
function parseLocaleDate(dateString: string = '', locale: string, timeZone: string): Date | null {
|
48
|
+
// This function may seem complicated but it basically does one thing:
|
49
|
+
// Given a dateString, a locale and a timeZone. The dateString is assumed to be formatted according
|
50
|
+
// to the locale. So if the locale is `en-us` the dateString is expected to be in the format of M/D/YYYY.
|
51
|
+
// The dateString is also assumed to be in the given timeZone, so "1/1/2020" in "America/Los_Angeles" timezone is
|
52
|
+
// expected to be "2020-01-01T08:00:00.000Z" in UTC time.
|
53
|
+
// This function tries to parse the dateString taking these variables into account and return a javascript Date object
|
54
|
+
// that is adjusted to be in UTC.
|
56
55
|
|
57
|
-
//
|
58
|
-
|
56
|
+
// Split string on '.', whitespace, '/', ',' or '-' using regex: /[.\s/.-]+/.
|
57
|
+
// The '+' allows splitting on consecutive delimiters.
|
58
|
+
// `.filter(Boolean)` is needed because some locales have a delimeter at the end (e.g.: hungarian dates are formatted as `2024. 09. 19.`)
|
59
|
+
const splitDate = dateString.split(/[,.\s/.-]+/).filter(Boolean)
|
59
60
|
|
60
|
-
//
|
61
|
-
|
62
|
-
|
61
|
+
// create a locale formatted new date to later extract the order and delimeter information
|
62
|
+
const localeDate = new Intl.DateTimeFormat(locale).formatToParts(new Date())
|
63
|
+
|
64
|
+
let index = 0
|
65
|
+
let day: number | undefined, month: number | undefined, year: number | undefined
|
66
|
+
localeDate.forEach((part) => {
|
67
|
+
if (part.type === 'month') {
|
68
|
+
month = parseInt(splitDate[index], 10)
|
69
|
+
index++
|
70
|
+
} else if (part.type === 'day') {
|
71
|
+
day = parseInt(splitDate[index], 10)
|
72
|
+
index++
|
73
|
+
} else if (part.type === 'year') {
|
74
|
+
year = parseInt(splitDate[index], 10)
|
75
|
+
index++
|
76
|
+
}
|
77
|
+
})
|
78
|
+
|
79
|
+
// sensible limitations
|
80
|
+
if (!year || !month || !day || year < 1000 || year > 9999) return null
|
81
|
+
|
82
|
+
// create utc date from year, month (zero indexed) and day
|
83
|
+
const date = new Date(Date.UTC(year, month - 1, day))
|
84
|
+
|
85
|
+
if (date.getMonth() !== month - 1 || date.getDate() !== day) {
|
86
|
+
// Check if the Date object adjusts the values. If it does, the input is invalid.
|
87
|
+
return null
|
63
88
|
}
|
64
89
|
|
65
|
-
//
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
90
|
+
// Format date string in the provided timezone. The locale here is irrelevant, we only care about how to time is adjusted for the timezone.
|
91
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
92
|
+
timeZone,
|
93
|
+
year: 'numeric',
|
94
|
+
month: '2-digit',
|
95
|
+
day: '2-digit',
|
96
|
+
hour: '2-digit',
|
97
|
+
minute: '2-digit',
|
98
|
+
second: '2-digit',
|
99
|
+
hour12: false,
|
100
|
+
}).formatToParts(date)
|
73
101
|
|
74
|
-
|
75
|
-
|
102
|
+
// Extract the date and time parts from the formatted string
|
103
|
+
const dateStringInTimezone: {
|
104
|
+
[key: string]: number
|
105
|
+
} = parts.reduce((acc, part) => {
|
106
|
+
return part.type === 'literal' ? acc : {
|
107
|
+
...acc,
|
108
|
+
[part.type]: part.value,
|
109
|
+
}
|
110
|
+
}, {})
|
76
111
|
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
timezone
|
81
|
-
)
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
day: 'numeric',
|
86
|
-
timeZone: timezone
|
87
|
-
})
|
112
|
+
// Create a date string in the format 'YYYY-MM-DDTHH:mm:ss'
|
113
|
+
const dateInTimezone = `${dateStringInTimezone.year}-${dateStringInTimezone.month}-${dateStringInTimezone.day}T${dateStringInTimezone.hour}:${dateStringInTimezone.minute}:${dateStringInTimezone.second}`
|
114
|
+
|
115
|
+
// Calculate time difference for timezone offset
|
116
|
+
const timeDiff = new Date(dateInTimezone + 'Z').getTime() - date.getTime()
|
117
|
+
const utcTime = new Date(date.getTime() - timeDiff)
|
118
|
+
// Return the UTC Date corresponding to the time in the specified timezone
|
119
|
+
return utcTime
|
88
120
|
}
|
89
121
|
|
90
122
|
/**
|
@@ -99,7 +131,6 @@ const DateInput2 = ({
|
|
99
131
|
screenReaderLabels,
|
100
132
|
isRequired = false,
|
101
133
|
interaction = 'enabled',
|
102
|
-
size = 'medium',
|
103
134
|
isInline = false,
|
104
135
|
value,
|
105
136
|
messages,
|
@@ -107,12 +138,12 @@ const DateInput2 = ({
|
|
107
138
|
onChange,
|
108
139
|
onBlur,
|
109
140
|
withYearPicker,
|
110
|
-
onRequestValidateDate,
|
111
141
|
invalidDateErrorMessage,
|
112
142
|
locale,
|
113
143
|
timezone,
|
114
144
|
placeholder,
|
115
|
-
|
145
|
+
dateFormat,
|
146
|
+
onRequestValidateDate,
|
116
147
|
// margin, TODO enable this prop
|
117
148
|
...rest
|
118
149
|
}: DateInput2Props) => {
|
@@ -138,26 +169,73 @@ const DateInput2 = ({
|
|
138
169
|
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
139
170
|
}
|
140
171
|
|
141
|
-
const [inputValue, setInputValue] = useState<string>(
|
142
|
-
value ? formatDate(value, getLocale(), getTimezone()) : ''
|
143
|
-
)
|
144
172
|
const [inputMessages, setInputMessages] = useState<FormMessage[]>(
|
145
173
|
messages || []
|
146
174
|
)
|
147
175
|
const [showPopover, setShowPopover] = useState<boolean>(false)
|
148
176
|
|
149
177
|
useEffect(() => {
|
150
|
-
|
178
|
+
// don't set input messages if there is an error set already
|
179
|
+
if (!inputMessages) {
|
180
|
+
setInputMessages(messages || [])
|
181
|
+
}
|
151
182
|
}, [messages])
|
152
183
|
|
153
184
|
useEffect(() => {
|
154
|
-
|
155
|
-
|
185
|
+
const [, utcIsoDate] = parseDate(value)
|
186
|
+
// clear error messages if date becomes valid
|
187
|
+
if (utcIsoDate || !value) {
|
188
|
+
setInputMessages(messages || [])
|
189
|
+
}
|
190
|
+
}, [value])
|
191
|
+
|
192
|
+
const parseDate = (dateString: string = ''): [string, string] => {
|
193
|
+
let date: Date | null = null
|
194
|
+
if (dateFormat) {
|
195
|
+
if (typeof dateFormat === 'string') {
|
196
|
+
// use dateFormat instead of the user locale
|
197
|
+
date = parseLocaleDate(dateString, dateFormat, getTimezone())
|
198
|
+
} else if (dateFormat.parser) {
|
199
|
+
date = dateFormat.parser(dateString)
|
200
|
+
}
|
201
|
+
} else {
|
202
|
+
// no dateFormat prop passed, use locale for formatting
|
203
|
+
date = parseLocaleDate(dateString, getLocale(), getTimezone())
|
204
|
+
}
|
205
|
+
return date ? [formatDate(date), date.toISOString()] : ['', '']
|
206
|
+
}
|
207
|
+
|
208
|
+
const formatDate = (date: Date): string => {
|
209
|
+
// use formatter function if provided
|
210
|
+
if (typeof dateFormat !== 'string' && dateFormat?.formatter) {
|
211
|
+
return dateFormat.formatter(date)
|
212
|
+
}
|
213
|
+
// if dateFormat set to a locale, use that, otherwise default to the user's locale
|
214
|
+
return date.toLocaleDateString(typeof dateFormat === 'string' ? dateFormat : getLocale(), {timeZone: getTimezone(), calendar: 'gregory', numberingSystem: 'latn'})
|
215
|
+
}
|
216
|
+
|
217
|
+
const getDateFromatHint = () => {
|
218
|
+
const exampleDate = new Date('2024-09-01')
|
219
|
+
const formattedDate = formatDate(exampleDate)
|
220
|
+
|
221
|
+
// Create a regular expression to find the exact match of the number
|
222
|
+
const regex = (n: string) => {
|
223
|
+
return new RegExp(`(?<!\\d)0*${n}(?!\\d)`, 'g')
|
224
|
+
}
|
225
|
+
|
226
|
+
// Replace the matched number with the same number of dashes
|
227
|
+
const year = `${exampleDate.getFullYear()}`
|
228
|
+
const month = `${exampleDate.getMonth() + 1}`
|
229
|
+
const day = `${exampleDate.getDate()}`
|
230
|
+
return formattedDate
|
231
|
+
.replace(regex(year), (match) => 'Y'.repeat(match.length))
|
232
|
+
.replace(regex(month), (match) => 'M'.repeat(match.length))
|
233
|
+
.replace(regex(day), (match) => 'D'.repeat(match.length))
|
234
|
+
}
|
156
235
|
|
157
236
|
const handleInputChange = (e: SyntheticEvent, newValue: string) => {
|
158
|
-
|
159
|
-
|
160
|
-
onChange?.(e, parsedInput, newValue)
|
237
|
+
const [, utcIsoDate] = parseDate(newValue)
|
238
|
+
onChange?.(e, newValue, utcIsoDate)
|
161
239
|
}
|
162
240
|
|
163
241
|
const handleDateSelected = (
|
@@ -165,50 +243,32 @@ const DateInput2 = ({
|
|
165
243
|
_momentDate: Moment,
|
166
244
|
e: SyntheticEvent
|
167
245
|
) => {
|
168
|
-
const formattedDate = formatDate(dateString, getLocale(), getTimezone())
|
169
|
-
setInputValue(formattedDate)
|
170
246
|
setShowPopover(false)
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
if (!inputValue || timezoneDateToUtc(inputValue, getTimezone()) || value) {
|
179
|
-
setInputMessages(messages || [])
|
180
|
-
return true
|
181
|
-
}
|
182
|
-
// only show error if there is no user provided validation callback
|
183
|
-
if (
|
184
|
-
!onlyRemoveError &&
|
185
|
-
typeof invalidDateErrorMessage === 'string' &&
|
186
|
-
!onRequestValidateDate
|
187
|
-
) {
|
188
|
-
setInputMessages([
|
189
|
-
{
|
190
|
-
type: 'error',
|
191
|
-
text: invalidDateErrorMessage
|
192
|
-
}
|
193
|
-
])
|
194
|
-
}
|
195
|
-
|
196
|
-
return false
|
247
|
+
const newValue = formatDate(new Date(dateString))
|
248
|
+
onChange?.(
|
249
|
+
e,
|
250
|
+
newValue,
|
251
|
+
dateString
|
252
|
+
)
|
253
|
+
onRequestValidateDate?.(e, newValue, dateString)
|
197
254
|
}
|
198
255
|
|
199
256
|
const handleBlur = (e: SyntheticEvent) => {
|
200
|
-
|
201
|
-
|
202
|
-
if (
|
203
|
-
|
204
|
-
onChange?.(e, value, formattedDate)
|
257
|
+
const [localeDate, utcIsoDate] = parseDate(value)
|
258
|
+
if (localeDate) {
|
259
|
+
if (localeDate !== value) {
|
260
|
+
onChange?.(e, localeDate, utcIsoDate)
|
205
261
|
}
|
262
|
+
} else if (value && invalidDateErrorMessage) {
|
263
|
+
setInputMessages([
|
264
|
+
{type: 'error', text: invalidDateErrorMessage}
|
265
|
+
])
|
206
266
|
}
|
207
|
-
|
208
|
-
|
209
|
-
onBlur?.(e)
|
267
|
+
onRequestValidateDate?.(e, value || '', utcIsoDate)
|
268
|
+
onBlur?.(e, value || '', utcIsoDate)
|
210
269
|
}
|
211
270
|
|
271
|
+
const selectedDate = parseDate(value)[1]
|
212
272
|
return (
|
213
273
|
<TextInput
|
214
274
|
{...passthroughProps(rest)}
|
@@ -217,10 +277,9 @@ const DateInput2 = ({
|
|
217
277
|
onChange={handleInputChange}
|
218
278
|
onBlur={handleBlur}
|
219
279
|
isRequired={isRequired}
|
220
|
-
value={
|
221
|
-
placeholder={placeholder}
|
280
|
+
value={value}
|
281
|
+
placeholder={placeholder ?? getDateFromatHint()}
|
222
282
|
width={width}
|
223
|
-
size={size}
|
224
283
|
display={isInline ? 'inline-block' : 'block'}
|
225
284
|
messages={inputMessages}
|
226
285
|
interaction={interaction}
|
@@ -232,7 +291,6 @@ const DateInput2 = ({
|
|
232
291
|
withBorder={false}
|
233
292
|
screenReaderLabel={screenReaderLabels.calendarIcon}
|
234
293
|
shape="circle"
|
235
|
-
size={size}
|
236
294
|
interaction={interaction}
|
237
295
|
>
|
238
296
|
<IconCalendarMonthLine />
|
@@ -249,8 +307,8 @@ const DateInput2 = ({
|
|
249
307
|
<Calendar
|
250
308
|
withYearPicker={withYearPicker}
|
251
309
|
onDateSelected={handleDateSelected}
|
252
|
-
selectedDate={
|
253
|
-
visibleMonth={
|
310
|
+
selectedDate={selectedDate}
|
311
|
+
visibleMonth={selectedDate}
|
254
312
|
locale={getLocale()}
|
255
313
|
timezone={getTimezone()}
|
256
314
|
role="listbox"
|
package/src/DateInput2/props.ts
CHANGED
@@ -45,16 +45,11 @@ type DateInput2OwnProps = {
|
|
45
45
|
nextMonthButton: string
|
46
46
|
}
|
47
47
|
/**
|
48
|
-
* Specifies the input value
|
48
|
+
* Specifies the input value.
|
49
49
|
*/
|
50
50
|
value?: string
|
51
51
|
/**
|
52
|
-
*
|
53
|
-
*/
|
54
|
-
size?: 'small' | 'medium' | 'large'
|
55
|
-
/**
|
56
|
-
* Html placeholder text to display when the input has no value. This should
|
57
|
-
* be hint text, not a label replacement.
|
52
|
+
* Placeholder text for the input field. If it's left undefined it will display a hint for the date format (like `DD/MM/YYYY`).
|
58
53
|
*/
|
59
54
|
placeholder?: string
|
60
55
|
/**
|
@@ -62,13 +57,13 @@ type DateInput2OwnProps = {
|
|
62
57
|
*/
|
63
58
|
onChange?: (
|
64
59
|
event: React.SyntheticEvent,
|
65
|
-
|
66
|
-
|
60
|
+
inputValue: string,
|
61
|
+
utcDateString: string
|
67
62
|
) => void
|
68
63
|
/**
|
69
64
|
* Callback executed when the input fires a blur event.
|
70
65
|
*/
|
71
|
-
onBlur?: (event: React.SyntheticEvent) => void
|
66
|
+
onBlur?: (event: React.SyntheticEvent, value: string, utcDateString: string) => void
|
72
67
|
/**
|
73
68
|
* Specifies if interaction with the input is enabled, disabled, or readonly.
|
74
69
|
* When "disabled", the input changes visibly to indicate that it cannot
|
@@ -98,15 +93,6 @@ type DateInput2OwnProps = {
|
|
98
93
|
* }`
|
99
94
|
*/
|
100
95
|
messages?: FormMessage[]
|
101
|
-
/**
|
102
|
-
* Callback fired when the input is blurred or a date is selected from the calendar.
|
103
|
-
* Feedback should be provided to the user when this function is called if the selected date or input
|
104
|
-
* value is invalid. The component has an internal check whether the date can be parsed to a valid date.
|
105
|
-
*/
|
106
|
-
onRequestValidateDate?: (
|
107
|
-
isoDateString?: string,
|
108
|
-
internalValidationPassed?: boolean
|
109
|
-
) => void | FormMessage[]
|
110
96
|
/**
|
111
97
|
* The message shown to the user when the date is invalid. If this prop is not set, validation is bypassed.
|
112
98
|
* If it's set to an empty string, validation happens and the input border changes to red if validation hasn't passed.
|
@@ -134,7 +120,7 @@ type DateInput2OwnProps = {
|
|
134
120
|
* This property can also be set via a context property and if both are set
|
135
121
|
* then the component property takes precedence over the context property.
|
136
122
|
*
|
137
|
-
* The
|
123
|
+
* The system timezone will be used if no value is set via a component
|
138
124
|
* property or a context property.
|
139
125
|
**/
|
140
126
|
timezone?: string
|
@@ -156,18 +142,19 @@ type DateInput2OwnProps = {
|
|
156
142
|
startYear: number
|
157
143
|
endYear: number
|
158
144
|
}
|
159
|
-
|
160
145
|
/**
|
161
|
-
|
162
|
-
|
163
|
-
|
146
|
+
* By default the date format is determined by the locale but can be changed via this prop to an alternate locale (passing it in as a string) or a custom parser and formatter (both as functions)
|
147
|
+
*/
|
148
|
+
dateFormat?: {
|
149
|
+
parser: (input: string) => Date | null
|
150
|
+
formatter: (date: Date) => string
|
151
|
+
} | string
|
164
152
|
|
165
153
|
/**
|
166
|
-
*
|
167
|
-
* `small`, `medium`, `large`, `x-large`, `xx-large`. Apply these values via
|
168
|
-
* familiar CSS-like shorthand. For example: `margin="small auto large"`.
|
154
|
+
* Callback executed when the input fires a blur event or a date is selected from the picker.
|
169
155
|
*/
|
170
|
-
|
156
|
+
onRequestValidateDate?: (event: React.SyntheticEvent, value: string, utcDateString: string) => void
|
157
|
+
// margin?: Spacing // TODO enable this prop
|
171
158
|
}
|
172
159
|
|
173
160
|
type PropKeys = keyof DateInput2OwnProps
|
@@ -182,7 +169,6 @@ const propTypes: PropValidators<PropKeys> = {
|
|
182
169
|
renderLabel: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
|
183
170
|
screenReaderLabels: PropTypes.object.isRequired,
|
184
171
|
value: controllable(PropTypes.string),
|
185
|
-
size: PropTypes.oneOf(['small', 'medium', 'large']),
|
186
172
|
placeholder: PropTypes.string,
|
187
173
|
onChange: PropTypes.func,
|
188
174
|
onBlur: PropTypes.func,
|
@@ -191,7 +177,6 @@ const propTypes: PropValidators<PropKeys> = {
|
|
191
177
|
isInline: PropTypes.bool,
|
192
178
|
width: PropTypes.string,
|
193
179
|
messages: PropTypes.arrayOf(FormPropTypes.message),
|
194
|
-
onRequestValidateDate: PropTypes.func,
|
195
180
|
invalidDateErrorMessage: PropTypes.oneOfType([
|
196
181
|
PropTypes.func,
|
197
182
|
PropTypes.string
|
@@ -199,7 +184,11 @@ const propTypes: PropValidators<PropKeys> = {
|
|
199
184
|
locale: PropTypes.string,
|
200
185
|
timezone: PropTypes.string,
|
201
186
|
withYearPicker: PropTypes.object,
|
202
|
-
|
187
|
+
dateFormat: PropTypes.oneOfType([
|
188
|
+
PropTypes.string,
|
189
|
+
PropTypes.object,
|
190
|
+
]),
|
191
|
+
onRequestValidateDate: PropTypes.func,
|
203
192
|
}
|
204
193
|
|
205
194
|
export type { DateInput2Props }
|