@instructure/ui-date-input 9.5.2 → 9.6.1-pr-snapshot-1726659472372

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,23 +44,88 @@ 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
- function parseDate(dateString: string): string {
48
- const date = new Date(dateString)
49
- // return empty string if not a valid date
50
- return isNaN(date.getTime()) ? '' : date.toISOString()
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.
55
+
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.: "hu-hu")
59
+ const splitDate = dateString.split(/[,.\s/.-]+/).filter(Boolean)
60
+
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 year 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
+ // Format date string in the provided timezone
86
+ const parts = new Intl.DateTimeFormat('en-US', {
87
+ timeZone,
88
+ year: 'numeric',
89
+ month: '2-digit',
90
+ day: '2-digit',
91
+ hour: '2-digit',
92
+ minute: '2-digit',
93
+ second: '2-digit',
94
+ hour12: false,
95
+ }).formatToParts(date)
96
+
97
+ // Extract the date and time parts from the formatted string
98
+ const dateStringInTimezone: {
99
+ [key: string]: number
100
+ } = parts.reduce((acc, part) => {
101
+ return part.type === 'literal' ? acc : {
102
+ ...acc,
103
+ [part.type]: part.value,
104
+ }
105
+ }, {})
106
+
107
+ // Create a date string in the format 'YYYY-MM-DDTHH:mm:ss'
108
+ const dateInTimezone = `${dateStringInTimezone.year}-${dateStringInTimezone.month}-${dateStringInTimezone.day}T${dateStringInTimezone.hour}:${dateStringInTimezone.minute}:${dateStringInTimezone.second}`
109
+
110
+ // Calculate time difference for timezone offset
111
+ const timeDiff = new Date(dateInTimezone + 'Z').getTime() - date.getTime()
112
+ const newTime = new Date(date.getTime() - timeDiff)
113
+ // Return the UTC Date corresponding to the time in the specified timezone
114
+ return newTime
51
115
  }
52
116
 
53
117
  /**
54
118
  ---
55
119
  category: components
56
120
  ---
121
+
122
+ @module experimental
57
123
  **/
58
124
  const DateInput2 = ({
59
125
  renderLabel,
60
126
  screenReaderLabels,
61
127
  isRequired = false,
62
128
  interaction = 'enabled',
63
- size = 'medium',
64
129
  isInline = false,
65
130
  value,
66
131
  messages,
@@ -68,85 +133,24 @@ const DateInput2 = ({
68
133
  onChange,
69
134
  onBlur,
70
135
  withYearPicker,
71
- onRequestValidateDate,
72
136
  invalidDateErrorMessage,
73
137
  locale,
74
138
  timezone,
75
139
  placeholder,
140
+ dateFormat,
141
+ onRequestValidateDate,
142
+ // margin, TODO enable this prop
76
143
  ...rest
77
144
  }: DateInput2Props) => {
78
- const [selectedDate, setSelectedDate] = useState<string>('')
79
- const [inputMessages, setInputMessages] = useState<FormMessage[]>(
80
- messages || []
81
- )
82
- const [showPopover, setShowPopover] = useState<boolean>(false)
83
145
  const localeContext = useContext(ApplyLocaleContext)
84
146
 
85
- useEffect(() => {
86
- // when `value` is changed, validation runs again and removes the error message if validation passes
87
- // but it's NOT adding error message if validation fails for better UX
88
- validateInput(true)
89
- }, [value])
90
-
91
- useEffect(() => {
92
- setInputMessages(messages || [])
93
- }, [messages])
94
-
95
- const handleInputChange = (e: SyntheticEvent, value: string) => {
96
- onChange?.(e, value)
97
- // blur event formats the input which should trigger parsing
98
- if (e.type !== 'blur') {
99
- setSelectedDate(parseDate(value))
100
- }
101
- }
102
-
103
- const handleDateSelected = (
104
- dateString: string,
105
- _momentDate: Moment,
106
- e: SyntheticEvent
107
- ) => {
108
- const formattedDate = new Date(dateString).toLocaleDateString(getLocale(), {
109
- month: 'long',
110
- year: 'numeric',
111
- day: 'numeric',
112
- timeZone: getTimezone()
113
- })
114
- handleInputChange(e, formattedDate)
115
- setSelectedDate(dateString)
116
- setShowPopover(false)
117
- onRequestValidateDate?.(dateString, true)
118
- }
119
-
120
- // onlyRemoveError is used to remove the error msg immediately when the user inputs a valid date (and don't wait for blur event)
121
- const validateInput = (onlyRemoveError = false): boolean => {
122
- // don't validate empty input
123
- if (!value || parseDate(value) || selectedDate) {
124
- setInputMessages(messages || [])
125
- return true
126
- }
127
- // only show error if there is no user provided validation callback
128
- if (
129
- !onlyRemoveError &&
130
- typeof invalidDateErrorMessage === 'string' &&
131
- !onRequestValidateDate
132
- ) {
133
- setInputMessages([
134
- {
135
- type: 'error',
136
- text: invalidDateErrorMessage
137
- }
138
- ])
139
- }
140
-
141
- return false
142
- }
143
-
144
147
  const getLocale = () => {
145
148
  if (locale) {
146
149
  return locale
147
150
  } else if (localeContext.locale) {
148
151
  return localeContext.locale
149
152
  }
153
+ // default to the system's locale
150
154
  return Locale.browserLocale()
151
155
  }
152
156
 
@@ -160,27 +164,88 @@ const DateInput2 = ({
160
164
  return Intl.DateTimeFormat().resolvedOptions().timeZone
161
165
  }
162
166
 
167
+ const [inputMessages, setInputMessages] = useState<FormMessage[]>(
168
+ messages || []
169
+ )
170
+ const [showPopover, setShowPopover] = useState<boolean>(false)
171
+
172
+ useEffect(() => {
173
+ setInputMessages(messages || [])
174
+ }, [messages])
175
+
176
+ useEffect(() => {
177
+ const [, utcIsoDate] = parseDate(value)
178
+ // clear error messages if date becomes valid
179
+ if (utcIsoDate || !value) {
180
+ setInputMessages(messages || [])
181
+ }
182
+ }, [value])
183
+
184
+ const parseDate = (dateString: string = ''): [string, string] => {
185
+ let date: Date | null = null
186
+ if (dateFormat) {
187
+ if (typeof dateFormat === 'string') {
188
+ // use dateFormat instead of the user locale
189
+ date = parseLocaleDate(dateString, dateFormat, getTimezone())
190
+ } else if (dateFormat.parser) {
191
+ date = dateFormat.parser(dateString)
192
+ }
193
+ } else {
194
+ // no dateFormat prop passed, use locale for formatting
195
+ date = parseLocaleDate(dateString, getLocale(), getTimezone())
196
+ }
197
+ return date ? [formatDate(date), date.toISOString()] : ['', '']
198
+ }
199
+
200
+ const formatDate = (date: Date): string => {
201
+ // use formatter function if provided
202
+ if (typeof dateFormat !== 'string' && dateFormat?.formatter) {
203
+ return dateFormat.formatter(date)
204
+ }
205
+ // if dateFormat set to a locale, use that, otherwise default to the user's locale
206
+ return date.toLocaleDateString(typeof dateFormat === 'string' ? dateFormat : getLocale(), {timeZone: getTimezone(), calendar: 'gregory', numberingSystem: 'latn'})
207
+ }
208
+
209
+ const handleInputChange = (e: SyntheticEvent, newValue: string) => {
210
+ const [, utcIsoDate] = parseDate(newValue)
211
+ onChange?.(e, newValue, utcIsoDate)
212
+ }
213
+
214
+ const handleDateSelected = (
215
+ dateString: string,
216
+ _momentDate: Moment,
217
+ e: SyntheticEvent
218
+ ) => {
219
+ setShowPopover(false)
220
+ const newValue = formatDate(new Date(dateString))
221
+ onChange?.(
222
+ e,
223
+ newValue,
224
+ dateString
225
+ )
226
+ onRequestValidateDate?.(e, newValue, dateString)
227
+ }
228
+
163
229
  const handleBlur = (e: SyntheticEvent) => {
164
- const isInputValid = validateInput(false)
165
- if (isInputValid && selectedDate) {
166
- const formattedDate = new Date(selectedDate).toLocaleDateString(
167
- getLocale(),
168
- {
169
- month: 'long',
170
- year: 'numeric',
171
- day: 'numeric',
172
- timeZone: getTimezone()
173
- }
174
- )
175
- handleInputChange(e, formattedDate)
230
+ const [localeDate, utcIsoDate] = parseDate(value)
231
+ if (localeDate) {
232
+ if (localeDate !== value) {
233
+ onChange?.(e, localeDate, utcIsoDate)
234
+ }
235
+ } else if (value && invalidDateErrorMessage) {
236
+ setInputMessages([
237
+ {type: 'error', text: invalidDateErrorMessage}
238
+ ])
176
239
  }
177
- onRequestValidateDate?.(value, isInputValid)
178
- onBlur?.(e)
240
+ onRequestValidateDate?.(e, value || '', utcIsoDate)
241
+ onBlur?.(e, value || '', utcIsoDate)
179
242
  }
180
243
 
244
+ const selectedDate = parseDate(value)[1]
181
245
  return (
182
246
  <TextInput
183
247
  {...passthroughProps(rest)}
248
+ // margin={'large'} TODO add this prop to TextInput
184
249
  renderLabel={renderLabel}
185
250
  onChange={handleInputChange}
186
251
  onBlur={handleBlur}
@@ -188,7 +253,6 @@ const DateInput2 = ({
188
253
  value={value}
189
254
  placeholder={placeholder}
190
255
  width={width}
191
- size={size}
192
256
  display={isInline ? 'inline-block' : 'block'}
193
257
  messages={inputMessages}
194
258
  interaction={interaction}
@@ -200,7 +264,6 @@ const DateInput2 = ({
200
264
  withBorder={false}
201
265
  screenReaderLabel={screenReaderLabels.calendarIcon}
202
266
  shape="circle"
203
- size={size}
204
267
  interaction={interaction}
205
268
  >
206
269
  <IconCalendarMonthLine />
@@ -219,8 +282,8 @@ const DateInput2 = ({
219
282
  onDateSelected={handleDateSelected}
220
283
  selectedDate={selectedDate}
221
284
  visibleMonth={selectedDate}
222
- locale={locale}
223
- timezone={timezone}
285
+ locale={getLocale()}
286
+ timezone={getTimezone()}
224
287
  role="listbox"
225
288
  renderNextMonthButton={
226
289
  <IconButton
@@ -28,7 +28,11 @@ import type { SyntheticEvent, InputHTMLAttributes } from 'react'
28
28
  import { controllable } from '@instructure/ui-prop-types'
29
29
  import { FormPropTypes } from '@instructure/ui-form-field'
30
30
  import type { FormMessage } from '@instructure/ui-form-field'
31
- import type { OtherHTMLAttributes, Renderable, PropValidators } from '@instructure/shared-types'
31
+ import type {
32
+ OtherHTMLAttributes,
33
+ Renderable,
34
+ PropValidators
35
+ } from '@instructure/shared-types'
32
36
 
33
37
  type DateInput2OwnProps = {
34
38
  /**
@@ -41,13 +45,9 @@ type DateInput2OwnProps = {
41
45
  nextMonthButton: string
42
46
  }
43
47
  /**
44
- * Specifies the input value.
48
+ * Specifies the input value *before* formatting. The `formatDate` will be applied to it before displaying. Should be a valid, parsable date.
45
49
  */
46
- value?: string // TODO: controllable(PropTypes.string)
47
- /**
48
- * Specifies the input size.
49
- */
50
- size?: 'small' | 'medium' | 'large'
50
+ value?: string
51
51
  /**
52
52
  * Html placeholder text to display when the input has no value. This should
53
53
  * be hint text, not a label replacement.
@@ -56,11 +56,15 @@ type DateInput2OwnProps = {
56
56
  /**
57
57
  * Callback fired when the input changes.
58
58
  */
59
- onChange?: (event: React.SyntheticEvent, value: string) => void
59
+ onChange?: (
60
+ event: React.SyntheticEvent,
61
+ value: string,
62
+ utcDateString: string
63
+ ) => void
60
64
  /**
61
65
  * Callback executed when the input fires a blur event.
62
66
  */
63
- onBlur?: (event: React.SyntheticEvent) => void
67
+ onBlur?: (event: React.SyntheticEvent, value: string, utcDateString: string) => void
64
68
  /**
65
69
  * Specifies if interaction with the input is enabled, disabled, or readonly.
66
70
  * When "disabled", the input changes visibly to indicate that it cannot
@@ -90,24 +94,6 @@ type DateInput2OwnProps = {
90
94
  * }`
91
95
  */
92
96
  messages?: FormMessage[]
93
- /**
94
- * Callback fired requesting the calendar be shown.
95
- */
96
- onRequestShowCalendar?: (event: SyntheticEvent) => void
97
- /**
98
- * Callback fired requesting the calendar be hidden.
99
- */
100
- onRequestHideCalendar?: (event: SyntheticEvent) => void
101
- /**
102
- * Callback fired when the input is blurred. Feedback should be provided
103
- * 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
105
- * be parsed to a valid date.
106
- */
107
- onRequestValidateDate?: (
108
- value?: string,
109
- internalValidationPassed?: boolean
110
- ) => void | FormMessage[]
111
97
  /**
112
98
  * The message shown to the user when the date is invalid. If this prop is not set, validation is bypassed.
113
99
  * If it's set to an empty string, validation happens and the input border changes to red if validation hasn't passed.
@@ -157,6 +143,19 @@ type DateInput2OwnProps = {
157
143
  startYear: number
158
144
  endYear: number
159
145
  }
146
+ /**
147
+ * 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)
148
+ */
149
+ dateFormat?: {
150
+ parser: (input: string) => Date
151
+ formatter: (date: Date) => string
152
+ } | string
153
+
154
+ /**
155
+ * Callback executed when the input fires a blur event or a date is selected from the picker.
156
+ */
157
+ onRequestValidateDate?: (event: React.SyntheticEvent, value: string, utcDateString: string) => void
158
+ // margin?: Spacing // TODO enable this prop
160
159
  }
161
160
 
162
161
  type PropKeys = keyof DateInput2OwnProps
@@ -171,7 +170,6 @@ const propTypes: PropValidators<PropKeys> = {
171
170
  renderLabel: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
172
171
  screenReaderLabels: PropTypes.object.isRequired,
173
172
  value: controllable(PropTypes.string),
174
- size: PropTypes.oneOf(['small', 'medium', 'large']),
175
173
  placeholder: PropTypes.string,
176
174
  onChange: PropTypes.func,
177
175
  onBlur: PropTypes.func,
@@ -180,16 +178,18 @@ const propTypes: PropValidators<PropKeys> = {
180
178
  isInline: PropTypes.bool,
181
179
  width: PropTypes.string,
182
180
  messages: PropTypes.arrayOf(FormPropTypes.message),
183
- onRequestShowCalendar: PropTypes.func,
184
- onRequestHideCalendar: PropTypes.func,
185
- onRequestValidateDate: PropTypes.func,
186
181
  invalidDateErrorMessage: PropTypes.oneOfType([
187
182
  PropTypes.func,
188
183
  PropTypes.string
189
184
  ]),
190
185
  locale: PropTypes.string,
191
186
  timezone: PropTypes.string,
192
- withYearPicker: PropTypes.object
187
+ withYearPicker: PropTypes.object,
188
+ dateFormat: PropTypes.oneOfType([
189
+ PropTypes.string,
190
+ PropTypes.object,
191
+ ]),
192
+ onRequestValidateDate: PropTypes.func,
193
193
  }
194
194
 
195
195
  export type { DateInput2Props }