@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.
- package/CHANGELOG.md +22 -0
- package/es/DateInput2/index.js +149 -77
- package/es/DateInput2/props.js +3 -5
- package/lib/DateInput2/index.js +149 -77
- package/lib/DateInput2/props.js +3 -5
- package/package.json +20 -20
- package/src/DateInput/README.md +1 -1
- package/src/DateInput2/README.md +186 -64
- package/src/DateInput2/index.tsx +151 -88
- package/src/DateInput2/props.ts +32 -32
- package/tsconfig.build.tsbuildinfo +1 -1
- package/types/DateInput2/index.d.ts +10 -13
- package/types/DateInput2/index.d.ts.map +1 -1
- package/types/DateInput2/props.d.ts +14 -22
- package/types/DateInput2/props.d.ts.map +1 -1
package/src/DateInput2/index.tsx
CHANGED
@@ -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
|
48
|
-
|
49
|
-
//
|
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())
|
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
|
165
|
-
if (
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
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,
|
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={
|
223
|
-
timezone={
|
285
|
+
locale={getLocale()}
|
286
|
+
timezone={getTimezone()}
|
224
287
|
role="listbox"
|
225
288
|
renderNextMonthButton={
|
226
289
|
<IconButton
|
package/src/DateInput2/props.ts
CHANGED
@@ -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 {
|
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
|
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?: (
|
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 }
|