@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.
@@ -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
- * Tries parsing a date in a given timezone, if it's not possible, returns an empty string
49
- * If parsing is successful an ISO formatted datetime string is returned in UTC timezone
50
- */
51
- function timezoneDateToUtc(dateString: string, timezone: string): string {
52
- // Don't try to parse short dateString, they are incomplete
53
- if (dateString.length < 10) {
54
- return ''
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
- // Create a Date object from the input date string
58
- const date = new Date(dateString)
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
- // Check if the date is valid
61
- if (isNaN(date.getTime())) {
62
- return ''
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
- // snippet from https://stackoverflow.com/a/57842203
66
- // but it might need to be improved:
67
- // "This produces incorrect datetimes for several hours surrounding daylight saving times if the
68
- // computer running the code is in a zone that doesn't obey the same daylight saving shifts as the target zone."
69
- const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }))
70
- const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }))
71
- const offset = utcDate.getTime() - tzDate.getTime()
72
- const newDate = new Date(date.getTime() + offset)
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
- return newDate.toISOString()
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
- function defaultDateFormatter(
78
- dateString: string,
79
- locale: string,
80
- timezone: string
81
- ) {
82
- return new Date(dateString).toLocaleDateString(locale, {
83
- month: 'long',
84
- year: 'numeric',
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
- formatDate = defaultDateFormatter,
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
- setInputMessages(messages || [])
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
- validateInput(true)
155
- }, [inputValue])
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
- setInputValue(newValue)
159
- const parsedInput = timezoneDateToUtc(newValue, getTimezone())
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
- onChange?.(e, dateString, formattedDate)
172
- onRequestValidateDate?.(dateString, true)
173
- }
174
-
175
- // onlyRemoveError is used to remove the error msg immediately when the user inputs a valid date (and don't wait for blur event)
176
- const validateInput = (onlyRemoveError = false): boolean => {
177
- // don't validate empty input
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
- if (value) {
201
- const formattedDate = formatDate(value, getLocale(), getTimezone())
202
- if (formattedDate !== inputValue) {
203
- setInputValue(formattedDate)
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
- validateInput(false)
208
- onRequestValidateDate?.(value, !!value)
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={inputValue}
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={value}
253
- visibleMonth={value}
310
+ selectedDate={selectedDate}
311
+ visibleMonth={selectedDate}
254
312
  locale={getLocale()}
255
313
  timezone={getTimezone()}
256
314
  role="listbox"
@@ -45,16 +45,11 @@ type DateInput2OwnProps = {
45
45
  nextMonthButton: string
46
46
  }
47
47
  /**
48
- * Specifies the input value *before* formatting. The `formatDate` will be applied to it before displaying. Should be a valid, parsable date.
48
+ * Specifies the input value.
49
49
  */
50
50
  value?: string
51
51
  /**
52
- * Specifies the input size.
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
- isoDateString: string,
66
- formattedValue: string
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 web browser's timezone will be used if no value is set via a component
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
- * Formatting function for how the date should be displayed inside the input field. It will be applied if the user clicks on a date in the date picker of after blur event from the input field.
162
- */
163
- formatDate?: (isoDate: string, locale: string, timezone: string) => string
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
- * Valid values are `0`, `none`, `auto`, `xxx-small`, `xx-small`, `x-small`,
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
- // margin?: Spacing TODO enable this prop
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
- formatDate: PropTypes.func
187
+ dateFormat: PropTypes.oneOfType([
188
+ PropTypes.string,
189
+ PropTypes.object,
190
+ ]),
191
+ onRequestValidateDate: PropTypes.func,
203
192
  }
204
193
 
205
194
  export type { DateInput2Props }