@instructure/ui-date-input 10.2.3-snapshot-10 → 10.2.3-snapshot-11

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.
@@ -25,7 +25,6 @@
25
25
  /** @jsx jsx */
26
26
  import { useState, useEffect, useContext } from 'react'
27
27
  import type { SyntheticEvent } from 'react'
28
- import moment from 'moment-timezone'
29
28
  import { Calendar } from '@instructure/ui-calendar'
30
29
  import { IconButton } from '@instructure/ui-buttons'
31
30
  import {
@@ -43,38 +42,103 @@ import { jsx } from '@instructure/emotion'
43
42
  import { propTypes } from './props'
44
43
  import type { DateInput2Props } from './props'
45
44
  import type { FormMessage } from '@instructure/ui-form-field'
45
+ import type { Moment } from '@instructure/ui-i18n'
46
46
 
47
- function isValidDate(dateString: string): boolean {
48
- return !isNaN(new Date(dateString).getTime())
49
- }
50
-
51
- function isValidMomentDate(
52
- dateString: string,
47
+ function parseLocaleDate(
48
+ dateString: string = '',
53
49
  locale: string,
54
- timezone: string
55
- ): boolean {
56
- return moment
57
- .tz(
58
- dateString,
59
- [moment.ISO_8601, 'llll', 'LLLL', 'lll', 'LLL', 'll', 'LL', 'l', 'L'],
60
- locale,
61
- true,
62
- timezone
63
- )
64
- .isValid()
50
+ timeZone: string
51
+ ): Date | null {
52
+ // This function may seem complicated but it basically does one thing:
53
+ // Given a dateString, a locale and a timeZone. The dateString is assumed to be formatted according
54
+ // to the locale. So if the locale is `en-us` the dateString is expected to be in the format of M/D/YYYY.
55
+ // The dateString is also assumed to be in the given timeZone, so "1/1/2020" in "America/Los_Angeles" timezone is
56
+ // expected to be "2020-01-01T08:00:00.000Z" in UTC time.
57
+ // This function tries to parse the dateString taking these variables into account and return a javascript Date object
58
+ // that is adjusted to be in UTC.
59
+
60
+ // Split string on '.', whitespace, '/', ',' or '-' using regex: /[.\s/.-]+/.
61
+ // The '+' allows splitting on consecutive delimiters.
62
+ // `.filter(Boolean)` is needed because some locales have a delimeter at the end (e.g.: hungarian dates are formatted as `2024. 09. 19.`)
63
+ const splitDate = dateString.split(/[,.\s/.-]+/).filter(Boolean)
64
+
65
+ // create a locale formatted new date to later extract the order and delimeter information
66
+ const localeDate = new Intl.DateTimeFormat(locale).formatToParts(new Date())
67
+
68
+ let index = 0
69
+ let day: number | undefined,
70
+ month: number | undefined,
71
+ year: number | undefined
72
+ localeDate.forEach((part) => {
73
+ if (part.type === 'month') {
74
+ month = parseInt(splitDate[index], 10)
75
+ index++
76
+ } else if (part.type === 'day') {
77
+ day = parseInt(splitDate[index], 10)
78
+ index++
79
+ } else if (part.type === 'year') {
80
+ year = parseInt(splitDate[index], 10)
81
+ index++
82
+ }
83
+ })
84
+
85
+ // sensible limitations
86
+ if (!year || !month || !day || year < 1000 || year > 9999) return null
87
+
88
+ // create utc date from year, month (zero indexed) and day
89
+ const date = new Date(Date.UTC(year, month - 1, day))
90
+
91
+ if (date.getMonth() !== month - 1 || date.getDate() !== day) {
92
+ // Check if the Date object adjusts the values. If it does, the input is invalid.
93
+ return null
94
+ }
95
+
96
+ // Format date string in the provided timezone. The locale here is irrelevant, we only care about how to time is adjusted for the timezone.
97
+ const parts = new Intl.DateTimeFormat('en-US', {
98
+ timeZone,
99
+ year: 'numeric',
100
+ month: '2-digit',
101
+ day: '2-digit',
102
+ hour: '2-digit',
103
+ minute: '2-digit',
104
+ second: '2-digit',
105
+ hour12: false
106
+ }).formatToParts(date)
107
+
108
+ // Extract the date and time parts from the formatted string
109
+ const dateStringInTimezone: {
110
+ [key: string]: number
111
+ } = parts.reduce((acc, part) => {
112
+ return part.type === 'literal'
113
+ ? acc
114
+ : {
115
+ ...acc,
116
+ [part.type]: part.value
117
+ }
118
+ }, {})
119
+
120
+ // Create a date string in the format 'YYYY-MM-DDTHH:mm:ss'
121
+ const dateInTimezone = `${dateStringInTimezone.year}-${dateStringInTimezone.month}-${dateStringInTimezone.day}T${dateStringInTimezone.hour}:${dateStringInTimezone.minute}:${dateStringInTimezone.second}`
122
+
123
+ // Calculate time difference for timezone offset
124
+ const timeDiff = new Date(dateInTimezone + 'Z').getTime() - date.getTime()
125
+ const utcTime = new Date(date.getTime() - timeDiff)
126
+ // Return the UTC Date corresponding to the time in the specified timezone
127
+ return utcTime
65
128
  }
66
129
 
67
130
  /**
68
131
  ---
69
132
  category: components
70
133
  ---
134
+
135
+ @module experimental
71
136
  **/
72
137
  const DateInput2 = ({
73
138
  renderLabel,
74
139
  screenReaderLabels,
75
140
  isRequired = false,
76
141
  interaction = 'enabled',
77
- size = 'medium',
78
142
  isInline = false,
79
143
  value,
80
144
  messages,
@@ -82,80 +146,24 @@ const DateInput2 = ({
82
146
  onChange,
83
147
  onBlur,
84
148
  withYearPicker,
85
- onRequestValidateDate,
86
149
  invalidDateErrorMessage,
87
150
  locale,
88
151
  timezone,
89
152
  placeholder,
153
+ dateFormat,
154
+ onRequestValidateDate,
155
+ // margin, TODO enable this prop
90
156
  ...rest
91
157
  }: DateInput2Props) => {
92
- const [selectedDate, setSelectedDate] = useState<string>('')
93
- const [inputMessages, setInputMessages] = useState<FormMessage[]>(
94
- messages || []
95
- )
96
- const [showPopover, setShowPopover] = useState<boolean>(false)
97
158
  const localeContext = useContext(ApplyLocaleContext)
98
159
 
99
- useEffect(() => {
100
- validateInput(true)
101
- }, [value])
102
-
103
- useEffect(() => {
104
- setInputMessages(messages || [])
105
- }, [messages])
106
-
107
- const handleInputChange = (e: SyntheticEvent, value: string) => {
108
- onChange?.(e, value)
109
- }
110
-
111
- const handleDateSelected = (
112
- dateString: string,
113
- _momentDate: any, // real type is Moment but used `any` to avoid importing the moment lib
114
- e: SyntheticEvent
115
- ) => {
116
- const formattedDate = new Date(dateString).toLocaleDateString(getLocale(), {
117
- month: 'long',
118
- year: 'numeric',
119
- day: 'numeric',
120
- timeZone: getTimezone()
121
- })
122
- handleInputChange(e, formattedDate)
123
- setShowPopover(false)
124
- onRequestValidateDate?.(formattedDate, true)
125
- }
126
-
127
- const validateInput = (onlyRemoveError = false): boolean => {
128
- // TODO `isValidDate` and `isValidMomentDate` basically have the same functionality but the latter is a bit more strict (e.g.: `33` is only valid in `isValidMomentDate`)
129
- // in the future we should get rid of moment but currently Calendar is using it for validation too so we can only remove it simultaneously
130
- // otherwise DateInput could pass invalid dates to Calendar and break it
131
- if (
132
- (isValidDate(value || '') &&
133
- isValidMomentDate(value || '', getLocale(), getTimezone())) ||
134
- value === ''
135
- ) {
136
- setSelectedDate(value || '')
137
- setInputMessages(messages || [])
138
- return true
139
- }
140
- if (!onlyRemoveError && typeof invalidDateErrorMessage === 'string') {
141
- setInputMessages((messages) => [
142
- {
143
- type: 'error',
144
- text: invalidDateErrorMessage
145
- },
146
- ...messages
147
- ])
148
- }
149
-
150
- return false
151
- }
152
-
153
160
  const getLocale = () => {
154
161
  if (locale) {
155
162
  return locale
156
163
  } else if (localeContext.locale) {
157
164
  return localeContext.locale
158
165
  }
166
+ // default to the system's locale
159
167
  return Locale.browserLocale()
160
168
  }
161
169
 
@@ -169,32 +177,114 @@ const DateInput2 = ({
169
177
  return Intl.DateTimeFormat().resolvedOptions().timeZone
170
178
  }
171
179
 
180
+ const [inputMessages, setInputMessages] = useState<FormMessage[]>(
181
+ messages || []
182
+ )
183
+ const [showPopover, setShowPopover] = useState<boolean>(false)
184
+
185
+ useEffect(() => {
186
+ // don't set input messages if there is an error set already
187
+ if (!inputMessages) {
188
+ setInputMessages(messages || [])
189
+ }
190
+ }, [messages])
191
+
192
+ useEffect(() => {
193
+ const [, utcIsoDate] = parseDate(value)
194
+ // clear error messages if date becomes valid
195
+ if (utcIsoDate || !value) {
196
+ setInputMessages(messages || [])
197
+ }
198
+ }, [value])
199
+
200
+ const parseDate = (dateString: string = ''): [string, string] => {
201
+ let date: Date | null = null
202
+ if (dateFormat) {
203
+ if (typeof dateFormat === 'string') {
204
+ // use dateFormat instead of the user locale
205
+ date = parseLocaleDate(dateString, dateFormat, getTimezone())
206
+ } else if (dateFormat.parser) {
207
+ date = dateFormat.parser(dateString)
208
+ }
209
+ } else {
210
+ // no dateFormat prop passed, use locale for formatting
211
+ date = parseLocaleDate(dateString, getLocale(), getTimezone())
212
+ }
213
+ return date ? [formatDate(date), date.toISOString()] : ['', '']
214
+ }
215
+
216
+ const formatDate = (date: Date): string => {
217
+ // use formatter function if provided
218
+ if (typeof dateFormat !== 'string' && dateFormat?.formatter) {
219
+ return dateFormat.formatter(date)
220
+ }
221
+ // if dateFormat set to a locale, use that, otherwise default to the user's locale
222
+ return date.toLocaleDateString(
223
+ typeof dateFormat === 'string' ? dateFormat : getLocale(),
224
+ { timeZone: getTimezone(), calendar: 'gregory', numberingSystem: 'latn' }
225
+ )
226
+ }
227
+
228
+ const getDateFromatHint = () => {
229
+ const exampleDate = new Date('2024-09-01')
230
+ const formattedDate = formatDate(exampleDate)
231
+
232
+ // Create a regular expression to find the exact match of the number
233
+ const regex = (n: string) => {
234
+ return new RegExp(`(?<!\\d)0*${n}(?!\\d)`, 'g')
235
+ }
236
+
237
+ // Replace the matched number with the same number of dashes
238
+ const year = `${exampleDate.getFullYear()}`
239
+ const month = `${exampleDate.getMonth() + 1}`
240
+ const day = `${exampleDate.getDate()}`
241
+ return formattedDate
242
+ .replace(regex(year), (match) => 'Y'.repeat(match.length))
243
+ .replace(regex(month), (match) => 'M'.repeat(match.length))
244
+ .replace(regex(day), (match) => 'D'.repeat(match.length))
245
+ }
246
+
247
+ const handleInputChange = (e: SyntheticEvent, newValue: string) => {
248
+ const [, utcIsoDate] = parseDate(newValue)
249
+ onChange?.(e, newValue, utcIsoDate)
250
+ }
251
+
252
+ const handleDateSelected = (
253
+ dateString: string,
254
+ _momentDate: Moment,
255
+ e: SyntheticEvent
256
+ ) => {
257
+ setShowPopover(false)
258
+ const newValue = formatDate(new Date(dateString))
259
+ onChange?.(e, newValue, dateString)
260
+ onRequestValidateDate?.(e, newValue, dateString)
261
+ }
262
+
172
263
  const handleBlur = (e: SyntheticEvent) => {
173
- const isInputValid = validateInput(false)
174
- if (isInputValid && value) {
175
- const formattedDate = new Date(value).toLocaleDateString(getLocale(), {
176
- month: 'long',
177
- year: 'numeric',
178
- day: 'numeric',
179
- timeZone: getTimezone()
180
- })
181
- handleInputChange(e, formattedDate)
264
+ const [localeDate, utcIsoDate] = parseDate(value)
265
+ if (localeDate) {
266
+ if (localeDate !== value) {
267
+ onChange?.(e, localeDate, utcIsoDate)
268
+ }
269
+ } else if (value && invalidDateErrorMessage) {
270
+ setInputMessages([{ type: 'error', text: invalidDateErrorMessage }])
182
271
  }
183
- onRequestValidateDate?.(value, isInputValid)
184
- onBlur?.(e)
272
+ onRequestValidateDate?.(e, value || '', utcIsoDate)
273
+ onBlur?.(e, value || '', utcIsoDate)
185
274
  }
186
275
 
276
+ const selectedDate = parseDate(value)[1]
187
277
  return (
188
278
  <TextInput
189
279
  {...passthroughProps(rest)}
280
+ // margin={'large'} TODO add this prop to TextInput
190
281
  renderLabel={renderLabel}
191
282
  onChange={handleInputChange}
192
283
  onBlur={handleBlur}
193
284
  isRequired={isRequired}
194
285
  value={value}
195
- placeholder={placeholder}
286
+ placeholder={placeholder ?? getDateFromatHint()}
196
287
  width={width}
197
- size={size}
198
288
  display={isInline ? 'inline-block' : 'block'}
199
289
  messages={inputMessages}
200
290
  interaction={interaction}
@@ -206,7 +296,6 @@ const DateInput2 = ({
206
296
  withBorder={false}
207
297
  screenReaderLabel={screenReaderLabels.calendarIcon}
208
298
  shape="circle"
209
- size={size}
210
299
  interaction={interaction}
211
300
  >
212
301
  <IconCalendarMonthLine />
@@ -225,8 +314,8 @@ const DateInput2 = ({
225
314
  onDateSelected={handleDateSelected}
226
315
  selectedDate={selectedDate}
227
316
  visibleMonth={selectedDate}
228
- locale={locale}
229
- timezone={timezone}
317
+ locale={getLocale()}
318
+ timezone={getTimezone()}
230
319
  role="listbox"
231
320
  renderNextMonthButton={
232
321
  <IconButton
@@ -23,14 +23,18 @@
23
23
  */
24
24
 
25
25
  import PropTypes from 'prop-types'
26
- import type { SyntheticEvent } from 'react'
26
+ import type { SyntheticEvent, InputHTMLAttributes } from 'react'
27
27
 
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 { Renderable, PropValidators } from '@instructure/shared-types'
31
+ import type {
32
+ OtherHTMLAttributes,
33
+ Renderable,
34
+ PropValidators
35
+ } from '@instructure/shared-types'
32
36
 
33
- type DateInput2Props = {
37
+ type DateInput2OwnProps = {
34
38
  /**
35
39
  * Specifies the input label.
36
40
  */
@@ -43,24 +47,27 @@ type DateInput2Props = {
43
47
  /**
44
48
  * Specifies the input value.
45
49
  */
46
- value?: string // TODO: controllable(PropTypes.string)
50
+ value?: string
47
51
  /**
48
- * Specifies the input size.
49
- */
50
- size?: 'small' | 'medium' | 'large'
51
- /**
52
- * Html placeholder text to display when the input has no value. This should
53
- * 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`).
54
53
  */
55
54
  placeholder?: string
56
55
  /**
57
56
  * Callback fired when the input changes.
58
57
  */
59
- onChange?: (event: React.SyntheticEvent, value: string) => void
58
+ onChange?: (
59
+ event: React.SyntheticEvent,
60
+ inputValue: string,
61
+ utcDateString: string
62
+ ) => void
60
63
  /**
61
64
  * Callback executed when the input fires a blur event.
62
65
  */
63
- onBlur?: (event: React.SyntheticEvent) => void
66
+ onBlur?: (
67
+ event: React.SyntheticEvent,
68
+ value: string,
69
+ utcDateString: string
70
+ ) => void
64
71
  /**
65
72
  * Specifies if interaction with the input is enabled, disabled, or readonly.
66
73
  * When "disabled", the input changes visibly to indicate that it cannot
@@ -90,24 +97,6 @@ type DateInput2Props = {
90
97
  * }`
91
98
  */
92
99
  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
100
  /**
112
101
  * The message shown to the user when the date is invalid. If this prop is not set, validation is bypassed.
113
102
  * If it's set to an empty string, validation happens and the input border changes to red if validation hasn't passed.
@@ -135,7 +124,7 @@ type DateInput2Props = {
135
124
  * This property can also be set via a context property and if both are set
136
125
  * then the component property takes precedence over the context property.
137
126
  *
138
- * The web browser's timezone will be used if no value is set via a component
127
+ * The system timezone will be used if no value is set via a component
139
128
  * property or a context property.
140
129
  **/
141
130
  timezone?: string
@@ -157,15 +146,39 @@ type DateInput2Props = {
157
146
  startYear: number
158
147
  endYear: number
159
148
  }
149
+ /**
150
+ * 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)
151
+ */
152
+ dateFormat?:
153
+ | {
154
+ parser: (input: string) => Date | null
155
+ formatter: (date: Date) => string
156
+ }
157
+ | string
158
+
159
+ /**
160
+ * Callback executed when the input fires a blur event or a date is selected from the picker.
161
+ */
162
+ onRequestValidateDate?: (
163
+ event: React.SyntheticEvent,
164
+ value: string,
165
+ utcDateString: string
166
+ ) => void
167
+ // margin?: Spacing // TODO enable this prop
160
168
  }
161
169
 
162
- type PropKeys = keyof DateInput2Props
170
+ type PropKeys = keyof DateInput2OwnProps
171
+
172
+ type DateInput2Props = DateInput2OwnProps &
173
+ OtherHTMLAttributes<
174
+ DateInput2OwnProps,
175
+ InputHTMLAttributes<DateInput2OwnProps & Element>
176
+ >
163
177
 
164
178
  const propTypes: PropValidators<PropKeys> = {
165
179
  renderLabel: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
166
180
  screenReaderLabels: PropTypes.object.isRequired,
167
181
  value: controllable(PropTypes.string),
168
- size: PropTypes.oneOf(['small', 'medium', 'large']),
169
182
  placeholder: PropTypes.string,
170
183
  onChange: PropTypes.func,
171
184
  onBlur: PropTypes.func,
@@ -174,16 +187,15 @@ const propTypes: PropValidators<PropKeys> = {
174
187
  isInline: PropTypes.bool,
175
188
  width: PropTypes.string,
176
189
  messages: PropTypes.arrayOf(FormPropTypes.message),
177
- onRequestShowCalendar: PropTypes.func,
178
- onRequestHideCalendar: PropTypes.func,
179
- onRequestValidateDate: PropTypes.func,
180
190
  invalidDateErrorMessage: PropTypes.oneOfType([
181
191
  PropTypes.func,
182
192
  PropTypes.string
183
193
  ]),
184
194
  locale: PropTypes.string,
185
195
  timezone: PropTypes.string,
186
- withYearPicker: PropTypes.object
196
+ withYearPicker: PropTypes.object,
197
+ dateFormat: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
198
+ onRequestValidateDate: PropTypes.func
187
199
  }
188
200
 
189
201
  export type { DateInput2Props }