@instructure/ui-date-input 9.6.0 → 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,35 +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 isNaN(date.getTime()) ? '' : date.toISOString()
50
- }
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())
51
63
 
52
- function defaultDateFormatter(
53
- dateString: string,
54
- locale: string,
55
- timezone: string
56
- ) {
57
- return new Date(dateString).toLocaleDateString(locale, {
58
- month: 'long',
59
- year: 'numeric',
60
- day: 'numeric',
61
- timeZone: timezone
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
+ }
62
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
63
115
  }
64
116
 
65
117
  /**
66
118
  ---
67
119
  category: components
68
120
  ---
121
+
122
+ @module experimental
69
123
  **/
70
124
  const DateInput2 = ({
71
125
  renderLabel,
72
126
  screenReaderLabels,
73
127
  isRequired = false,
74
128
  interaction = 'enabled',
75
- size = 'medium',
76
129
  isInline = false,
77
130
  value,
78
131
  messages,
@@ -80,46 +133,82 @@ const DateInput2 = ({
80
133
  onChange,
81
134
  onBlur,
82
135
  withYearPicker,
83
- onRequestValidateDate,
84
136
  invalidDateErrorMessage,
85
137
  locale,
86
138
  timezone,
87
139
  placeholder,
88
- formatDate = defaultDateFormatter,
140
+ dateFormat,
141
+ onRequestValidateDate,
89
142
  // margin, TODO enable this prop
90
143
  ...rest
91
144
  }: DateInput2Props) => {
92
- const [selectedDate, setSelectedDate] = useState<string>('')
145
+ const localeContext = useContext(ApplyLocaleContext)
146
+
147
+ const getLocale = () => {
148
+ if (locale) {
149
+ return locale
150
+ } else if (localeContext.locale) {
151
+ return localeContext.locale
152
+ }
153
+ // default to the system's locale
154
+ return Locale.browserLocale()
155
+ }
156
+
157
+ const getTimezone = () => {
158
+ if (timezone) {
159
+ return timezone
160
+ } else if (localeContext.timezone) {
161
+ return localeContext.timezone
162
+ }
163
+ // default to the system's timezone
164
+ return Intl.DateTimeFormat().resolvedOptions().timeZone
165
+ }
166
+
93
167
  const [inputMessages, setInputMessages] = useState<FormMessage[]>(
94
168
  messages || []
95
169
  )
96
170
  const [showPopover, setShowPopover] = useState<boolean>(false)
97
- const localeContext = useContext(ApplyLocaleContext)
98
-
99
- useEffect(() => {
100
- // when `value` is changed, validation removes the error message if passes
101
- // but it's NOT adding error message if validation fails for better UX
102
- validateInput(true)
103
- }, [value])
104
171
 
105
172
  useEffect(() => {
106
173
  setInputMessages(messages || [])
107
174
  }, [messages])
108
175
 
109
176
  useEffect(() => {
110
- setSelectedDate(parseDate(value || ''))
111
- }, [])
177
+ const [, utcIsoDate] = parseDate(value)
178
+ // clear error messages if date becomes valid
179
+ if (utcIsoDate || !value) {
180
+ setInputMessages(messages || [])
181
+ }
182
+ }, [value])
112
183
 
113
- const handleInputChange = (
114
- e: SyntheticEvent,
115
- newValue: string,
116
- parsedDate: string = ''
117
- ) => {
118
- // blur event formats the input which shouldn't trigger parsing
119
- if (e.type !== 'blur') {
120
- setSelectedDate(parseDate(newValue))
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())
121
196
  }
122
- onChange?.(e, newValue, parsedDate)
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)
123
212
  }
124
213
 
125
214
  const handleDateSelected = (
@@ -127,67 +216,32 @@ const DateInput2 = ({
127
216
  _momentDate: Moment,
128
217
  e: SyntheticEvent
129
218
  ) => {
130
- const formattedDate = formatDate(dateString, getLocale(), getTimezone())
131
- const parsedDate = parseDate(dateString)
132
- setSelectedDate(parsedDate)
133
- handleInputChange(e, formattedDate, parsedDate)
134
219
  setShowPopover(false)
135
- onRequestValidateDate?.(dateString, true)
220
+ const newValue = formatDate(new Date(dateString))
221
+ onChange?.(
222
+ e,
223
+ newValue,
224
+ dateString
225
+ )
226
+ onRequestValidateDate?.(e, newValue, dateString)
136
227
  }
137
228
 
138
- // onlyRemoveError is used to remove the error msg immediately when the user inputs a valid date (and don't wait for blur event)
139
- const validateInput = (onlyRemoveError = false): boolean => {
140
- // don't validate empty input
141
- if (!value || parseDate(value) || selectedDate) {
142
- setInputMessages(messages || [])
143
- return true
144
- }
145
- // only show error if there is no user provided validation callback
146
- if (
147
- !onlyRemoveError &&
148
- typeof invalidDateErrorMessage === 'string' &&
149
- !onRequestValidateDate
150
- ) {
229
+ const handleBlur = (e: SyntheticEvent) => {
230
+ const [localeDate, utcIsoDate] = parseDate(value)
231
+ if (localeDate) {
232
+ if (localeDate !== value) {
233
+ onChange?.(e, localeDate, utcIsoDate)
234
+ }
235
+ } else if (value && invalidDateErrorMessage) {
151
236
  setInputMessages([
152
- {
153
- type: 'error',
154
- text: invalidDateErrorMessage
155
- }
237
+ {type: 'error', text: invalidDateErrorMessage}
156
238
  ])
157
239
  }
158
-
159
- return false
160
- }
161
-
162
- const getLocale = () => {
163
- if (locale) {
164
- return locale
165
- } else if (localeContext.locale) {
166
- return localeContext.locale
167
- }
168
- return Locale.browserLocale()
169
- }
170
-
171
- const getTimezone = () => {
172
- if (timezone) {
173
- return timezone
174
- } else if (localeContext.timezone) {
175
- return localeContext.timezone
176
- }
177
- // default to the system's timezone
178
- return Intl.DateTimeFormat().resolvedOptions().timeZone
179
- }
180
-
181
- const handleBlur = (e: SyntheticEvent) => {
182
- const isInputValid = validateInput(false)
183
- if (isInputValid && selectedDate) {
184
- const formattedDate = formatDate(selectedDate, getLocale(), getTimezone())
185
- handleInputChange(e, formattedDate, selectedDate)
186
- }
187
- onRequestValidateDate?.(value, isInputValid)
188
- onBlur?.(e)
240
+ onRequestValidateDate?.(e, value || '', utcIsoDate)
241
+ onBlur?.(e, value || '', utcIsoDate)
189
242
  }
190
243
 
244
+ const selectedDate = parseDate(value)[1]
191
245
  return (
192
246
  <TextInput
193
247
  {...passthroughProps(rest)}
@@ -199,7 +253,6 @@ const DateInput2 = ({
199
253
  value={value}
200
254
  placeholder={placeholder}
201
255
  width={width}
202
- size={size}
203
256
  display={isInline ? 'inline-block' : 'block'}
204
257
  messages={inputMessages}
205
258
  interaction={interaction}
@@ -211,7 +264,6 @@ const DateInput2 = ({
211
264
  withBorder={false}
212
265
  screenReaderLabel={screenReaderLabels.calendarIcon}
213
266
  shape="circle"
214
- size={size}
215
267
  interaction={interaction}
216
268
  >
217
269
  <IconCalendarMonthLine />
@@ -45,13 +45,9 @@ type DateInput2OwnProps = {
45
45
  nextMonthButton: string
46
46
  }
47
47
  /**
48
- * 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.
49
49
  */
50
- value?: string // TODO: controllable(PropTypes.string)
51
- /**
52
- * Specifies the input size.
53
- */
54
- size?: 'small' | 'medium' | 'large'
50
+ value?: string
55
51
  /**
56
52
  * Html placeholder text to display when the input has no value. This should
57
53
  * be hint text, not a label replacement.
@@ -62,13 +58,13 @@ type DateInput2OwnProps = {
62
58
  */
63
59
  onChange?: (
64
60
  event: React.SyntheticEvent,
65
- inputValue: string,
66
- dateString: string
61
+ value: string,
62
+ utcDateString: string
67
63
  ) => void
68
64
  /**
69
65
  * Callback executed when the input fires a blur event.
70
66
  */
71
- onBlur?: (event: React.SyntheticEvent) => void
67
+ onBlur?: (event: React.SyntheticEvent, value: string, utcDateString: string) => void
72
68
  /**
73
69
  * Specifies if interaction with the input is enabled, disabled, or readonly.
74
70
  * When "disabled", the input changes visibly to indicate that it cannot
@@ -98,24 +94,6 @@ type DateInput2OwnProps = {
98
94
  * }`
99
95
  */
100
96
  messages?: FormMessage[]
101
- /**
102
- * Callback fired requesting the calendar be shown.
103
- */
104
- onRequestShowCalendar?: (event: SyntheticEvent) => void
105
- /**
106
- * Callback fired requesting the calendar be hidden.
107
- */
108
- onRequestHideCalendar?: (event: SyntheticEvent) => void
109
- /**
110
- * Callback fired when the input is blurred. Feedback should be provided
111
- * to the user when this function is called if the selected date or input
112
- * value is invalid. The component has an internal check whether the date can
113
- * be parsed to a valid date.
114
- */
115
- onRequestValidateDate?: (
116
- value?: string,
117
- internalValidationPassed?: boolean
118
- ) => void | FormMessage[]
119
97
  /**
120
98
  * The message shown to the user when the date is invalid. If this prop is not set, validation is bypassed.
121
99
  * If it's set to an empty string, validation happens and the input border changes to red if validation hasn't passed.
@@ -165,18 +143,19 @@ type DateInput2OwnProps = {
165
143
  startYear: number
166
144
  endYear: number
167
145
  }
168
-
169
146
  /**
170
- * 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.
171
- */
172
- formatDate?: (isoDate: string, locale: string, timezone: string) => string
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
173
153
 
174
154
  /**
175
- * Valid values are `0`, `none`, `auto`, `xxx-small`, `xx-small`, `x-small`,
176
- * `small`, `medium`, `large`, `x-large`, `xx-large`. Apply these values via
177
- * familiar CSS-like shorthand. For example: `margin="small auto large"`.
155
+ * Callback executed when the input fires a blur event or a date is selected from the picker.
178
156
  */
179
- // margin?: Spacing TODO enable this prop
157
+ onRequestValidateDate?: (event: React.SyntheticEvent, value: string, utcDateString: string) => void
158
+ // margin?: Spacing // TODO enable this prop
180
159
  }
181
160
 
182
161
  type PropKeys = keyof DateInput2OwnProps
@@ -191,7 +170,6 @@ const propTypes: PropValidators<PropKeys> = {
191
170
  renderLabel: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
192
171
  screenReaderLabels: PropTypes.object.isRequired,
193
172
  value: controllable(PropTypes.string),
194
- size: PropTypes.oneOf(['small', 'medium', 'large']),
195
173
  placeholder: PropTypes.string,
196
174
  onChange: PropTypes.func,
197
175
  onBlur: PropTypes.func,
@@ -200,9 +178,6 @@ const propTypes: PropValidators<PropKeys> = {
200
178
  isInline: PropTypes.bool,
201
179
  width: PropTypes.string,
202
180
  messages: PropTypes.arrayOf(FormPropTypes.message),
203
- onRequestShowCalendar: PropTypes.func,
204
- onRequestHideCalendar: PropTypes.func,
205
- onRequestValidateDate: PropTypes.func,
206
181
  invalidDateErrorMessage: PropTypes.oneOfType([
207
182
  PropTypes.func,
208
183
  PropTypes.string
@@ -210,7 +185,11 @@ const propTypes: PropValidators<PropKeys> = {
210
185
  locale: PropTypes.string,
211
186
  timezone: PropTypes.string,
212
187
  withYearPicker: PropTypes.object,
213
- formatDate: PropTypes.func
188
+ dateFormat: PropTypes.oneOfType([
189
+ PropTypes.string,
190
+ PropTypes.object,
191
+ ]),
192
+ onRequestValidateDate: PropTypes.func,
214
193
  }
215
194
 
216
195
  export type { DateInput2Props }