@instructure/ui-date-input 9.5.2 → 9.6.1-snapshot-2

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.
@@ -2,16 +2,53 @@
2
2
  describes: DateInput2
3
3
  ---
4
4
 
5
- This component is an updated version of [`DateInput`](/#DateInput) that's easier to configure for developers, has a better UX, better accessibility features and a year picker. We recommend using this instead of `DateInput` which will be deprecated in the future.
5
+ `DateInput2` is an experimental upgrade to the existing [`DateInput`](/#DateInput) component, offering easier configuration, better UX, improved accessibility, and a year picker. While it addresses key limitations of `DateInput`, it's still in the experimental phase, with some missing unit tests and potential (though unlikely) API changes.
6
+
7
+ `DateInput` will be deprecated in the future, but for now, developers can start using `DateInput2` and provide feedback.
6
8
 
7
9
  ### Minimal config
8
10
 
9
11
  - ```js
10
12
  class Example extends React.Component {
11
- state = { value: '' }
13
+ initialDate = '2024-09-09T14:00:00.000Z'
14
+ state = { dateString: this.initialDate, inputValue: '' }
12
15
 
13
16
  render() {
14
17
  return (
18
+ <div>
19
+ <DateInput2
20
+ renderLabel="Choose a date"
21
+ screenReaderLabels={{
22
+ calendarIcon: 'Calendar',
23
+ nextMonthButton: 'Next month',
24
+ prevMonthButton: 'Previous month'
25
+ }}
26
+ value={this.state.dateString}
27
+ width="20rem"
28
+ onChange={(e, dateString, inputValue) => {
29
+ this.setState({ dateString, inputValue })
30
+ }}
31
+ invalidDateErrorMessage="Invalid date"
32
+ />
33
+ <p>
34
+ UTC Date String: <code>{this.state.dateString}</code>
35
+ <br />
36
+ Input Value: <code>{this.state.inputValue}</code>
37
+ </p>
38
+ </div>
39
+ )
40
+ }
41
+ }
42
+
43
+ render(<Example />)
44
+ ```
45
+
46
+ - ```js
47
+ const Example = () => {
48
+ const [dateString, setDateString] = useState('')
49
+ const [inputValue, setInputValue] = useState('')
50
+ return (
51
+ <div>
15
52
  <DateInput2
16
53
  renderLabel="Choose a date"
17
54
  screenReaderLabels={{
@@ -19,39 +56,35 @@ This component is an updated version of [`DateInput`](/#DateInput) that's easier
19
56
  nextMonthButton: 'Next month',
20
57
  prevMonthButton: 'Previous month'
21
58
  }}
22
- value={this.state.value}
59
+ value={dateString}
23
60
  width="20rem"
24
- onChange={(e, value) => this.setState({ value })}
61
+ onChange={(e, newDateString, newInputValue) => {
62
+ setDateString(newDateString)
63
+ setInputValue(newInputValue)
64
+ }}
25
65
  invalidDateErrorMessage="Invalid date"
26
66
  />
27
- )
28
- }
67
+ <p>
68
+ UTC Date String: <code>{dateString}</code>
69
+ <br />
70
+ Input Value: <code>{inputValue}</code>
71
+ </p>
72
+ </div>
73
+ )
29
74
  }
30
75
 
31
76
  render(<Example />)
32
77
  ```
33
78
 
34
- - ```js
35
- const Example = () => {
36
- const [value, setValue] = useState('')
37
- return (
38
- <DateInput2
39
- renderLabel="Choose a date"
40
- screenReaderLabels={{
41
- calendarIcon: 'Calendar',
42
- nextMonthButton: 'Next month',
43
- prevMonthButton: 'Previous month'
44
- }}
45
- value={value}
46
- width="20rem"
47
- onChange={(e, value) => setValue(value)}
48
- invalidDateErrorMessage="Invalid date"
49
- />
50
- )
51
- }
79
+ ### Timezones and UTC
52
80
 
53
- render(<Example />)
54
- ```
81
+ In the example above you can see that the date is set via the `value` prop and returned from the `onChange` callback. This date is expected to be in UTC timezone. So if a user chooses September 10th 2024 with the timezone 'Europe/Budapest', the `onChange` function will return `2024-09-09T22:00:00.000Z` because Budapest is two hours ahead of UTC.
82
+
83
+ Altought it would be nice to use the date picker without timezones and leave the time out alltogether but unfortunately you cannot decouple time from dates since the timezone determines when a day ends and another starts. In certain cases this changes the month or even the year. This can affect how you store and load dates from your database: if you want to set a saved date and that date is already timezone adjusted, you have to set it to utc with your date library of choice.
84
+
85
+ ### Parsing dates
86
+
87
+ When typing a date in the input field instead of using the included picker, the component tries to parse the date as you type it in. To prevent premature parsing (e.g. interpreting `2024` as `2024-01-01T00:00:00.000Z`) parsing only turns on after 10 character. Typed in dates are expected to be in [Date Time String Format](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). Any other format are implementation dependant and might differ browser by browser.
55
88
 
56
89
  ### With year picker
57
90
 
@@ -113,7 +146,11 @@ This component is an updated version of [`DateInput`](/#DateInput) that's easier
113
146
  render(<Example />)
114
147
  ```
115
148
 
116
- ### With custom validation
149
+ ### Date validation
150
+
151
+ By default `DateInput2` only does date validation if the `invalidDateErrorMessage` prop is provided. This uses the browser's `Date` object to try and parse the user provided date and displays the error message if it fails. Validation is triggered on the blur event of the input field.
152
+
153
+ If you want to do a more complex validation than the above (e.g. only allow a subset of dates) you can use the `onRequestValidateDate` prop to pass a validation function. This function will run on blur or on selecting a date from the picker. The result of the internal validation will be passed to this function. Then you have to set the error messages accordingly. Check the following example for more details:
117
154
 
118
155
  ```js
119
156
  ---
@@ -163,3 +200,101 @@ const Example = () => {
163
200
 
164
201
  render(<Example />)
165
202
  ```
203
+
204
+ ### Date formatting
205
+
206
+ The display format of the date value can be set via the `formatDate` property. It will be applied if the user clicks on a date in the date picker or after the blur event from the input field.
207
+ Something to pay attention to is that the date string passed back in the callback function **is in UTC timezone**.
208
+
209
+ ```js
210
+ ---
211
+ type: example
212
+ ---
213
+ const Example = () => {
214
+ const [value1, setValue1] = useState('')
215
+ const [value2, setValue2] = useState('')
216
+ const [value3, setValue3] = useState('')
217
+
218
+ const shortDateFormatFn = (dateString, locale, timezone) => {
219
+ return new Date(dateString).toLocaleDateString(locale, {
220
+ month: 'numeric',
221
+ year: 'numeric',
222
+ day: 'numeric',
223
+ timeZone: timezone,
224
+ })
225
+ }
226
+
227
+ const isoDateFormatFn = (dateString, locale, timezone) => {
228
+ // this is a simple way to get ISO8601 date in a specific timezone but should not be used in production
229
+ // please use a proper date library instead like date-fns, luxon or dayjs
230
+ const localeDate = new Date(dateString).toLocaleDateString('sv', {
231
+ month: 'numeric',
232
+ year: 'numeric',
233
+ day: 'numeric',
234
+ timeZone: timezone,
235
+ })
236
+
237
+ return localeDate
238
+ }
239
+
240
+ return (
241
+ <div style={{display: 'flex', flexDirection: 'column', gap: '1.5rem'}}>
242
+ <DateInput2
243
+ renderLabel="Default format"
244
+ screenReaderLabels={{
245
+ calendarIcon: 'Calendar',
246
+ nextMonthButton: 'Next month',
247
+ prevMonthButton: 'Previous month'
248
+ }}
249
+ isInline
250
+ width="20rem"
251
+ value={value1}
252
+ onChange={(e, value) => setValue1(value)}
253
+ withYearPicker={{
254
+ screenReaderLabel: 'Year picker',
255
+ startYear: 1900,
256
+ endYear: 2024
257
+ }}
258
+ />
259
+ <DateInput2
260
+ renderLabel="Short format in current locale"
261
+ screenReaderLabels={{
262
+ calendarIcon: 'Calendar',
263
+ nextMonthButton: 'Next month',
264
+ prevMonthButton: 'Previous month'
265
+ }}
266
+ isInline
267
+ width="20rem"
268
+ value={value2}
269
+ onChange={(e, value) => setValue2(value)}
270
+ formatDate={shortDateFormatFn}
271
+ withYearPicker={{
272
+ screenReaderLabel: 'Year picker',
273
+ startYear: 1900,
274
+ endYear: 2024
275
+ }}
276
+ />
277
+ <DateInput2
278
+ renderLabel="ISO8601"
279
+ screenReaderLabels={{
280
+ calendarIcon: 'Calendar',
281
+ nextMonthButton: 'Next month',
282
+ prevMonthButton: 'Previous month'
283
+ }}
284
+ isInline
285
+ width="20rem"
286
+ value={value3}
287
+ onChange={(e, value) => setValue3(value)}
288
+ formatDate={isoDateFormatFn}
289
+ withYearPicker={{
290
+ screenReaderLabel: 'Year picker',
291
+ startYear: 1900,
292
+ endYear: 2024
293
+ }}
294
+ />
295
+ </div>
296
+ )
297
+ }
298
+
299
+ render(<Example />)
300
+ ```
@@ -44,16 +44,55 @@ 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 {
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
+ }
56
+
57
+ // Create a Date object from the input date string
48
58
  const date = new Date(dateString)
49
- // return empty string if not a valid date
50
- return isNaN(date.getTime()) ? '' : date.toISOString()
59
+
60
+ // Check if the date is valid
61
+ if (isNaN(date.getTime())) {
62
+ return ''
63
+ }
64
+
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)
73
+
74
+ return newDate.toISOString()
75
+ }
76
+
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
+ })
51
88
  }
52
89
 
53
90
  /**
54
91
  ---
55
92
  category: components
56
93
  ---
94
+
95
+ @module experimental
57
96
  **/
58
97
  const DateInput2 = ({
59
98
  renderLabel,
@@ -73,31 +112,52 @@ const DateInput2 = ({
73
112
  locale,
74
113
  timezone,
75
114
  placeholder,
115
+ formatDate = defaultDateFormatter,
116
+ // margin, TODO enable this prop
76
117
  ...rest
77
118
  }: DateInput2Props) => {
78
- const [selectedDate, setSelectedDate] = useState<string>('')
119
+ const localeContext = useContext(ApplyLocaleContext)
120
+
121
+ const getLocale = () => {
122
+ if (locale) {
123
+ return locale
124
+ } else if (localeContext.locale) {
125
+ return localeContext.locale
126
+ }
127
+ // default to the system's locale
128
+ return Locale.browserLocale()
129
+ }
130
+
131
+ const getTimezone = () => {
132
+ if (timezone) {
133
+ return timezone
134
+ } else if (localeContext.timezone) {
135
+ return localeContext.timezone
136
+ }
137
+ // default to the system's timezone
138
+ return Intl.DateTimeFormat().resolvedOptions().timeZone
139
+ }
140
+
141
+ const [inputValue, setInputValue] = useState<string>(
142
+ value ? formatDate(value, getLocale(), getTimezone()) : ''
143
+ )
79
144
  const [inputMessages, setInputMessages] = useState<FormMessage[]>(
80
145
  messages || []
81
146
  )
82
147
  const [showPopover, setShowPopover] = useState<boolean>(false)
83
- const localeContext = useContext(ApplyLocaleContext)
84
-
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
148
 
91
149
  useEffect(() => {
92
150
  setInputMessages(messages || [])
93
151
  }, [messages])
94
152
 
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
- }
153
+ useEffect(() => {
154
+ validateInput(true)
155
+ }, [inputValue])
156
+
157
+ const handleInputChange = (e: SyntheticEvent, newValue: string) => {
158
+ setInputValue(newValue)
159
+ const parsedInput = timezoneDateToUtc(newValue, getTimezone())
160
+ onChange?.(e, parsedInput, newValue)
101
161
  }
102
162
 
103
163
  const handleDateSelected = (
@@ -105,22 +165,17 @@ const DateInput2 = ({
105
165
  _momentDate: Moment,
106
166
  e: SyntheticEvent
107
167
  ) => {
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)
168
+ const formattedDate = formatDate(dateString, getLocale(), getTimezone())
169
+ setInputValue(formattedDate)
116
170
  setShowPopover(false)
171
+ onChange?.(e, dateString, formattedDate)
117
172
  onRequestValidateDate?.(dateString, true)
118
173
  }
119
174
 
120
175
  // onlyRemoveError is used to remove the error msg immediately when the user inputs a valid date (and don't wait for blur event)
121
176
  const validateInput = (onlyRemoveError = false): boolean => {
122
177
  // don't validate empty input
123
- if (!value || parseDate(value) || selectedDate) {
178
+ if (!inputValue || timezoneDateToUtc(inputValue, getTimezone()) || value) {
124
179
  setInputMessages(messages || [])
125
180
  return true
126
181
  }
@@ -141,51 +196,28 @@ const DateInput2 = ({
141
196
  return false
142
197
  }
143
198
 
144
- const getLocale = () => {
145
- if (locale) {
146
- return locale
147
- } else if (localeContext.locale) {
148
- return localeContext.locale
149
- }
150
- return Locale.browserLocale()
151
- }
152
-
153
- const getTimezone = () => {
154
- if (timezone) {
155
- return timezone
156
- } else if (localeContext.timezone) {
157
- return localeContext.timezone
158
- }
159
- // default to the system's timezone
160
- return Intl.DateTimeFormat().resolvedOptions().timeZone
161
- }
162
-
163
199
  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)
200
+ if (value) {
201
+ const formattedDate = formatDate(value, getLocale(), getTimezone())
202
+ if (formattedDate !== inputValue) {
203
+ setInputValue(formattedDate)
204
+ onChange?.(e, value, formattedDate)
205
+ }
176
206
  }
177
- onRequestValidateDate?.(value, isInputValid)
207
+ validateInput(false)
208
+ onRequestValidateDate?.(value, !!value)
178
209
  onBlur?.(e)
179
210
  }
180
211
 
181
212
  return (
182
213
  <TextInput
183
214
  {...passthroughProps(rest)}
215
+ // margin={'large'} TODO add this prop to TextInput
184
216
  renderLabel={renderLabel}
185
217
  onChange={handleInputChange}
186
218
  onBlur={handleBlur}
187
219
  isRequired={isRequired}
188
- value={value}
220
+ value={inputValue}
189
221
  placeholder={placeholder}
190
222
  width={width}
191
223
  size={size}
@@ -217,10 +249,10 @@ const DateInput2 = ({
217
249
  <Calendar
218
250
  withYearPicker={withYearPicker}
219
251
  onDateSelected={handleDateSelected}
220
- selectedDate={selectedDate}
221
- visibleMonth={selectedDate}
222
- locale={locale}
223
- timezone={timezone}
252
+ selectedDate={value}
253
+ visibleMonth={value}
254
+ locale={getLocale()}
255
+ timezone={getTimezone()}
224
256
  role="listbox"
225
257
  renderNextMonthButton={
226
258
  <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,9 +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)
50
+ value?: string
47
51
  /**
48
52
  * Specifies the input size.
49
53
  */
@@ -56,7 +60,11 @@ type DateInput2OwnProps = {
56
60
  /**
57
61
  * Callback fired when the input changes.
58
62
  */
59
- onChange?: (event: React.SyntheticEvent, value: string) => void
63
+ onChange?: (
64
+ event: React.SyntheticEvent,
65
+ isoDateString: string,
66
+ formattedValue: string
67
+ ) => void
60
68
  /**
61
69
  * Callback executed when the input fires a blur event.
62
70
  */
@@ -91,21 +99,12 @@ type DateInput2OwnProps = {
91
99
  */
92
100
  messages?: FormMessage[]
93
101
  /**
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.
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.
106
105
  */
107
106
  onRequestValidateDate?: (
108
- value?: string,
107
+ isoDateString?: string,
109
108
  internalValidationPassed?: boolean
110
109
  ) => void | FormMessage[]
111
110
  /**
@@ -157,6 +156,18 @@ type DateInput2OwnProps = {
157
156
  startYear: number
158
157
  endYear: number
159
158
  }
159
+
160
+ /**
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
164
+
165
+ /**
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"`.
169
+ */
170
+ // margin?: Spacing TODO enable this prop
160
171
  }
161
172
 
162
173
  type PropKeys = keyof DateInput2OwnProps
@@ -180,8 +191,6 @@ const propTypes: PropValidators<PropKeys> = {
180
191
  isInline: PropTypes.bool,
181
192
  width: PropTypes.string,
182
193
  messages: PropTypes.arrayOf(FormPropTypes.message),
183
- onRequestShowCalendar: PropTypes.func,
184
- onRequestHideCalendar: PropTypes.func,
185
194
  onRequestValidateDate: PropTypes.func,
186
195
  invalidDateErrorMessage: PropTypes.oneOfType([
187
196
  PropTypes.func,
@@ -189,7 +198,8 @@ const propTypes: PropValidators<PropKeys> = {
189
198
  ]),
190
199
  locale: PropTypes.string,
191
200
  timezone: PropTypes.string,
192
- withYearPicker: PropTypes.object
201
+ withYearPicker: PropTypes.object,
202
+ formatDate: PropTypes.func
193
203
  }
194
204
 
195
205
  export type { DateInput2Props }