@instructure/ui-time-select 8.37.1-snapshot-13 → 8.38.0

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.
@@ -80,6 +80,7 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
80
80
  static contextType = ApplyLocaleContext
81
81
 
82
82
  ref: Select | null = null
83
+
83
84
  private readonly _emptyOptionId =
84
85
  this.props.deterministicId!('Select-EmptyOption')
85
86
 
@@ -144,7 +145,8 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
144
145
  this.props.step !== prevProps.step ||
145
146
  this.props.format !== prevProps.format ||
146
147
  this.props.locale !== prevProps.locale ||
147
- this.props.timezone !== prevProps.timezone
148
+ this.props.timezone !== prevProps.timezone ||
149
+ this.props.allowNonStepInput !== prevProps.allowNonStepInput
148
150
  ) {
149
151
  // options change, reset everything
150
152
  // when controlled, selection will be preserved
@@ -152,15 +154,35 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
152
154
  this.setState(this.getInitialState())
153
155
  }
154
156
  if (this.props.value !== prevProps.value) {
155
- const normalizedValue = this.normalizeISOTime(this.props.value)
157
+ let newValue: Moment | undefined
158
+ if (this.props.value) {
159
+ newValue = DateTime.parse(
160
+ this.props.value,
161
+ this.locale(),
162
+ this.timezone()
163
+ )
164
+ }
156
165
  // value changed
157
166
  const initState = this.getInitialState()
158
167
  this.setState(initState)
159
168
  // options need to be passed because state is not set immediately
160
- let option = this.getOption('value', normalizedValue, initState.options)
161
- if (typeof normalizedValue === 'undefined') {
169
+ let option
170
+ if (!this.isControlled) {
162
171
  // preserve current value when changing from controlled to uncontrolled
163
- option = this.getOption('value', this.normalizeISOTime(prevProps.value))
172
+ if (prevProps.value) {
173
+ option = this.getOption(
174
+ 'id',
175
+ this.getFormattedId(
176
+ DateTime.parse(prevProps.value, this.locale(), this.timezone())
177
+ )
178
+ )
179
+ }
180
+ } else if (newValue) {
181
+ option = this.getOption(
182
+ 'id',
183
+ this.getFormattedId(newValue),
184
+ initState.options
185
+ )
164
186
  }
165
187
  const outsideVal = this.props.value ? this.props.value : ''
166
188
  // value does not match an existing option
@@ -178,13 +200,10 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
178
200
  }
179
201
  }
180
202
 
181
- // value needs to be normalized because e.g. 2022-03-29T19:00Z and
182
- // 2022-03-29T19:00:00.000Z refer to the same time in the same timezone.
183
- normalizeISOTime(value?: string) {
184
- if (!value) {
185
- return value
186
- }
187
- return DateTime.parse(value, this.locale(), this.timezone()).toISOString()
203
+ getFormattedId(date: Moment) {
204
+ // ISO8601 strings may contain a space. Remove any spaces before using the
205
+ // date as the id.
206
+ return date.toISOString().replace(/\s/g, '')
188
207
  }
189
208
 
190
209
  getInitialState(): TimeSelectState {
@@ -193,33 +212,36 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
193
212
  return {
194
213
  inputValue: initialSelection ? initialSelection.label : '',
195
214
  options: initialOptions,
196
- filteredOptions: initialOptions,
215
+ // 288 = 5 min step
216
+ filteredOptions:
217
+ initialOptions.length > 288
218
+ ? initialOptions.filter(
219
+ (opt) => opt.value.minute() % this.props.step! === 0
220
+ )
221
+ : initialOptions,
197
222
  isShowingOptions: false,
198
223
  highlightedOptionId: initialSelection ? initialSelection.id : undefined,
199
224
  selectedOptionId: initialSelection ? initialSelection.id : undefined
200
225
  }
201
226
  }
202
227
 
203
- getInitialOption(
204
- options: TimeSelectOptions[]
205
- ): TimeSelectOptions | undefined {
228
+ getInitialOption(options: TimeSelectOptions[]) {
206
229
  const { value, defaultValue, defaultToFirstOption, format } = this.props
207
230
  const initialValue = value || defaultValue
208
-
209
231
  if (typeof initialValue === 'string') {
232
+ const date = DateTime.parse(initialValue, this.locale(), this.timezone())
210
233
  // get option based on value or defaultValue, if provided
211
- const option = this.getOption(
212
- 'value',
213
- this.normalizeISOTime(initialValue),
214
- options
215
- )
234
+ const option = this.getOption('value', date, options)
216
235
  if (option) {
217
236
  // value matches an existing option
218
237
  return option
219
238
  }
220
239
  // value does not match an existing option
221
- const date = DateTime.parse(initialValue, this.locale(), this.timezone())
222
- return { label: format ? date.format(format) : date.toISOString() }
240
+ return {
241
+ id: this.getFormattedId(date),
242
+ label: format ? date.format(format) : date.toISOString(),
243
+ value: date
244
+ } as TimeSelectOptions
223
245
  }
224
246
  // otherwise, return first option, if desired
225
247
  if (defaultToFirstOption) {
@@ -232,12 +254,6 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
232
254
  return options.find((option) => option[field] === value)
233
255
  }
234
256
 
235
- getFormattedId(date: Moment) {
236
- // ISO8601 strings may contain a space. Remove any spaces before using the
237
- // date as the id.
238
- return date.toISOString().replace(/\s/g, '')
239
- }
240
-
241
257
  getBaseDate() {
242
258
  let baseDate
243
259
  const baseValue = this.props.value || this.props.defaultValue
@@ -249,18 +265,20 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
249
265
  return baseDate.set({ second: 0, millisecond: 0 }).clone()
250
266
  }
251
267
 
252
- generateOptions() {
268
+ generateOptions(): TimeSelectOptions[] {
253
269
  const date = this.getBaseDate()
254
270
  const options = []
255
-
271
+ const step = this.props.step ? this.props.step : 30
272
+ const maxMinute = this.props.allowNonStepInput ? 60 : 60 / step
273
+ const minuteStep = this.props.allowNonStepInput ? 1 : step
256
274
  for (let hour = 0; hour < 24; hour++) {
257
- for (let minute = 0; minute < 60 / this.props.step!; minute++) {
258
- const minutes = minute * this.props.step!
275
+ for (let minute = 0; minute < maxMinute; minute++) {
276
+ const minutes = minute * minuteStep
259
277
  const newDate = date.set({ hour: hour, minute: minutes })
260
278
  // store time options
261
279
  options.push({
262
- id: this.getFormattedId(newDate), // iso no spaces
263
- value: newDate.toISOString(),
280
+ id: this.getFormattedId(newDate),
281
+ value: newDate.clone(),
264
282
  label: this.props.format
265
283
  ? newDate.format(this.props.format)
266
284
  : newDate.toISOString()
@@ -271,51 +289,29 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
271
289
  }
272
290
 
273
291
  filterOptions(inputValue: string) {
274
- return this.state?.options.filter((option: TimeSelectOptions) =>
275
- option.label.toLowerCase().startsWith(inputValue.toLowerCase())
276
- )
277
- }
278
-
279
- matchValue() {
280
- const {
281
- inputValue,
282
- filteredOptions,
283
- highlightedOptionId,
284
- selectedOptionId
285
- } = this.state
286
-
287
- // an option matching user input exists
288
- if (filteredOptions.length === 1) {
289
- const onlyOption = filteredOptions[0]
290
- // automatically select the matching option
291
- if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) {
292
- return {
293
- inputValue: onlyOption.label,
294
- selectedOptionId: onlyOption.id,
295
- filteredOptions: this.filterOptions('')
296
- }
297
- }
298
- }
299
- // no match found, return selected option label to input
300
- const selectedOption = this.getOption('id', selectedOptionId)
301
- if (selectedOption) {
302
- return { inputValue: selectedOption.label }
303
- }
304
- // input value is from highlighted option, not user input
305
- if (highlightedOptionId) {
306
- if (inputValue === this.getOption('id', highlightedOptionId)!.label) {
307
- return {
308
- inputValue: '',
309
- filteredOptions: this.filterOptions('')
310
- }
292
+ let inputNoSeconds = inputValue
293
+ // if the input contains seconds disregard them (e.g. if format = LTS)
294
+ if (inputValue.length > 5) {
295
+ // e.g. "5:34:"
296
+ const input = this.parseInputText(inputValue)
297
+ if (input.isValid()) {
298
+ input.set({ second: 0 })
299
+ inputNoSeconds = input.format(this.props.format)
311
300
  }
312
301
  }
313
- // if input was completely cleared, ensure it stays clear
314
- // e.g. defaultValue defined, but no selection yet made
315
- if (inputValue === '') {
316
- return { inputValue: '' }
302
+
303
+ if (this.props.allowNonStepInput && inputNoSeconds.length < 3) {
304
+ // could show too many results, show only step values
305
+ return this.state?.options.filter((option: TimeSelectOptions) => {
306
+ return (
307
+ option.label.toLowerCase().startsWith(inputNoSeconds.toLowerCase()) &&
308
+ option.value.minute() % this.props.step! == 0
309
+ )
310
+ })
317
311
  }
318
- return undefined
312
+ return this.state?.options.filter((option: TimeSelectOptions) =>
313
+ option.label.toLowerCase().startsWith(inputNoSeconds.toLowerCase())
314
+ )
319
315
  }
320
316
 
321
317
  handleRef = (node: Select) => {
@@ -323,13 +319,29 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
323
319
  }
324
320
 
325
321
  handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
326
- this.setState({ highlightedOptionId: undefined })
327
322
  this.props.onBlur?.(event)
328
323
  }
329
324
 
330
325
  handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
331
326
  const value = event.target.value
332
327
  const newOptions = this.filterOptions(value)
328
+ if (newOptions?.length == 1) {
329
+ // if there is only 1 option, it will be automatically selected except
330
+ // if in controlled mode (it would commit this change)
331
+ if (!this.isControlled) {
332
+ this.setState({ selectedOptionId: newOptions[0].id })
333
+ }
334
+ this.setState({ fireChangeOnBlur: newOptions[0] })
335
+ } else {
336
+ this.setState({
337
+ // needs not to lose selectedOptionId in controlled mode otherwise it'd
338
+ // revert to the default or '' instead of the set value
339
+ selectedOptionId: this.isControlled
340
+ ? this.state.selectedOptionId
341
+ : undefined,
342
+ fireChangeOnBlur: undefined
343
+ })
344
+ }
333
345
  this.setState({
334
346
  inputValue: value,
335
347
  filteredOptions: newOptions,
@@ -347,6 +359,22 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
347
359
  )
348
360
  }
349
361
 
362
+ onKeyDown = (event: React.KeyboardEvent<any>) => {
363
+ const input = this.parseInputText(this.state.inputValue)
364
+ if (
365
+ event.key === 'Enter' &&
366
+ this.props.allowNonStepInput &&
367
+ input.isValid()
368
+ ) {
369
+ this.setState(() => ({
370
+ isShowingOptions: false,
371
+ highlightedOptionId: undefined
372
+ }))
373
+ // others are set in handleBlurOrEsc
374
+ }
375
+ this.props.onKeyDown?.(event)
376
+ }
377
+
350
378
  handleShowOptions = (event: React.SyntheticEvent) => {
351
379
  this.setState({
352
380
  isShowingOptions: true,
@@ -355,8 +383,11 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
355
383
  this.props.onShowOptions?.(event)
356
384
  }
357
385
 
358
- handleHideOptions: SelectProps['onRequestHideOptions'] = (event) => {
359
- const input = this.parseInputText(this.state.inputValue)
386
+ // Called when the input is blurred (=when clicking outside, tabbing away),
387
+ // when pressing ESC. NOT called when an item is selected via Enter/click,
388
+ // (but in this case it will be called later when the input is blurred.)
389
+ handleBlurOrEsc: SelectProps['onRequestHideOptions'] = (event) => {
390
+ const { selectedOptionId, inputValue } = this.state
360
391
  let defaultValue = ''
361
392
  if (this.props.defaultValue) {
362
393
  const date = DateTime.parse(
@@ -368,87 +399,81 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
368
399
  ? date.format(this.props.format)
369
400
  : date.toISOString()
370
401
  }
371
- if (
372
- !this.props.allowNonStepInput ||
373
- (this.props.allowNonStepInput &&
374
- input.isValid() &&
375
- input.minute() % this.props.step! === 0)
376
- ) {
377
- const { selectedOptionId } = this.state
378
- const option = this.getOption('id', selectedOptionId)
379
- this.setState(() => ({
380
- isShowingOptions: false,
381
- highlightedOptionId: undefined,
382
- inputValue: selectedOptionId ? option!.label : defaultValue,
383
- filteredOptions: this.filterOptions(''),
384
- ...this.matchValue()
385
- }))
386
- } else {
387
- this.setState(() => ({
388
- isShowingOptions: false,
389
- highlightedOptionId: undefined,
390
- inputValue: this.state.lastValidInput || defaultValue || '',
391
- filteredOptions: this.filterOptions('')
392
- }))
402
+ const selectedOption = this.getOption('id', selectedOptionId)
403
+ let newInputValue = defaultValue
404
+ if (selectedOption) {
405
+ // If there is a selected option use its value in the input field.
406
+ newInputValue = selectedOption.label
393
407
  }
394
- this.props.onHideOptions?.(event)
395
- }
396
-
397
- handleHighlightOption: SelectProps['onRequestHighlightOption'] = (
398
- event: React.SyntheticEvent,
399
- { id }
400
- ) => {
401
- if (id === this._emptyOptionId) return
402
- const { type } = event
403
- const option = this.getOption('id', id)!.label
404
-
405
- this.setState((state) => ({
406
- highlightedOptionId: id,
407
- inputValue: type === 'keydown' ? option : state.inputValue
408
+ // if input was completely cleared, ensure it stays clear
409
+ // e.g. defaultValue defined, but no selection yet made
410
+ else if (inputValue === '') {
411
+ newInputValue = ''
412
+ }
413
+ this.setState(() => ({
414
+ isShowingOptions: false,
415
+ highlightedOptionId: undefined,
416
+ inputValue: newInputValue,
417
+ filteredOptions: this.filterOptions('')
408
418
  }))
419
+ if (this.state.fireChangeOnBlur && (event as any).key !== 'Escape') {
420
+ this.setState(() => ({ fireChangeOnBlur: undefined }))
421
+ this.props.onChange?.(event, {
422
+ value: this.state.fireChangeOnBlur.value.toISOString(),
423
+ inputText: this.state.fireChangeOnBlur.label
424
+ })
425
+ }
426
+ // TODO only fire this if handleSelectOption was not called before.
427
+ this.props.onHideOptions?.(event)
409
428
  }
410
429
 
411
- handleSelectOption: SelectProps['onRequestSelectOption'] = (
412
- event: React.SyntheticEvent,
413
- { id }
414
- ) => {
415
- if (id === this._emptyOptionId) {
430
+ // Called when an option is selected via mouse click or Enter.
431
+ handleSelectOption: SelectProps['onRequestSelectOption'] = (event, data) => {
432
+ if (data.id === this._emptyOptionId) {
416
433
  this.setState({ isShowingOptions: false })
417
434
  return
418
435
  }
419
- const option = this.getOption('id', id)!
420
-
436
+ const selectedOption = this.getOption('id', data.id)
421
437
  let newInputValue: string
438
+ const currentSelectedOptionId = this.state.selectedOptionId
422
439
  if (this.isControlled) {
440
+ // in controlled mode we leave to the user to set the value of the
441
+ // component e.g. in the onChange event handler.
442
+ // This is accomplished by not setting a selectedOptionId
423
443
  const prev = this.getOption('id', this.state.selectedOptionId)
424
444
  newInputValue = prev ? prev.label : ''
425
445
  this.setState({
426
- isShowingOptions: false,
427
- inputValue: newInputValue,
428
- filteredOptions: this.filterOptions('')
446
+ isShowingOptions: false
429
447
  })
430
448
  } else {
431
- newInputValue = option.label
449
+ newInputValue = selectedOption!.label
432
450
  this.setState({
433
451
  isShowingOptions: false,
434
- selectedOptionId: id,
435
- inputValue: newInputValue,
436
- filteredOptions: this.filterOptions('')
452
+ selectedOptionId: data.id,
453
+ inputValue: newInputValue
437
454
  })
438
455
  }
439
-
440
- if (id !== this.state.selectedOptionId) {
441
- this.setState({
442
- lastValidInput: newInputValue
443
- })
456
+ if (data.id !== currentSelectedOptionId) {
444
457
  this.props.onChange?.(event, {
445
- value: option.value,
458
+ value: selectedOption!.value.toISOString(),
446
459
  inputText: newInputValue
447
460
  })
448
461
  }
449
462
  this.props.onHideOptions?.(event)
450
463
  }
451
464
 
465
+ handleHighlightOption: SelectProps['onRequestHighlightOption'] = (
466
+ event,
467
+ data
468
+ ) => {
469
+ if (data.id === this._emptyOptionId) return
470
+ const option = this.getOption('id', data.id)!.label
471
+ this.setState((state) => ({
472
+ highlightedOptionId: data.id,
473
+ inputValue: event.type === 'keydown' ? option : state.inputValue
474
+ }))
475
+ }
476
+
452
477
  renderOptions() {
453
478
  const { filteredOptions, highlightedOptionId, selectedOptionId } =
454
479
  this.state
@@ -461,7 +486,7 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
461
486
  const { id, label } = option
462
487
  return (
463
488
  <Select.Option
464
- id={id!}
489
+ id={id}
465
490
  key={id}
466
491
  isHighlighted={id === highlightedOptionId}
467
492
  isSelected={id === selectedOptionId}
@@ -484,31 +509,6 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
484
509
  )
485
510
  }
486
511
 
487
- onKeyDown = (event: React.KeyboardEvent<any>) => {
488
- const input = this.parseInputText(this.state.inputValue)
489
- // validate input and if OK close dropdown
490
- if (
491
- event.key === 'Enter' &&
492
- this.props.allowNonStepInput &&
493
- input.isValid() &&
494
- input.minute() % this.props.step! !== 0 &&
495
- this.state.lastValidInput != this.state.inputValue
496
- ) {
497
- this.setState(() => ({
498
- isShowingOptions: false,
499
- highlightedOptionId: undefined,
500
- filteredOptions: this.filterOptions(''),
501
- lastValidInput: this.state.inputValue
502
- }))
503
- this.props.onChange?.(event, {
504
- value: input.toISOString(),
505
- inputText: this.state.inputValue
506
- })
507
- // others are set in OnHideOptions
508
- }
509
- this.props.onKeyDown?.(event)
510
- }
511
-
512
512
  parseInputText = (inputValue: string) => {
513
513
  const input = DateTime.parse(
514
514
  inputValue,
@@ -580,7 +580,7 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
580
580
  renderAfterInput={renderAfterInput}
581
581
  isShowingOptions={isShowingOptions}
582
582
  onRequestShowOptions={this.handleShowOptions}
583
- onRequestHideOptions={this.handleHideOptions}
583
+ onRequestHideOptions={this.handleBlurOrEsc}
584
584
  onRequestHighlightOption={this.handleHighlightOption}
585
585
  onRequestSelectOption={this.handleSelectOption}
586
586
  onInputChange={this.handleInputChange}
@@ -42,6 +42,7 @@ import type {
42
42
  } from '@instructure/ui-position'
43
43
  import type { WithDeterministicIdProps } from '@instructure/ui-react-utils'
44
44
  import { Renderable } from '@instructure/shared-types'
45
+ import type { Moment } from 'moment-timezone'
45
46
 
46
47
  type PropKeys = keyof TimeSelectOwnProps
47
48
 
@@ -78,6 +79,9 @@ type TimeSelectOwnProps = {
78
79
  id?: string
79
80
  /**
80
81
  * The format to use when displaying the possible and currently selected options.
82
+ * This component currently rounds seconds down to the minute.
83
+ * Defaults to `LT`, which is localized time without seconds, e.g. "16:45" or
84
+ * "4:45 PM"
81
85
  *
82
86
  * See [moment](https://momentjs.com/docs/#/displaying/format/) for the list
83
87
  * of available formats.
@@ -141,13 +145,16 @@ type TimeSelectOwnProps = {
141
145
  */
142
146
  mountNode?: PositionMountNode
143
147
  /**
144
- * Callback fired when a new option is selected.
148
+ * Callback fired when a new option is selected. This can happen in the
149
+ * following ways:
150
+ * 1. User clicks/presses enter on an option in the dropdown and focuses away
151
+ * 2. User enters a valid time manually and focuses away
145
152
  * @param event - the event object
146
- * @param data - additional data
153
+ * @param data - additional data containing the value and the input string
147
154
  */
148
155
  onChange?: (
149
156
  event: React.SyntheticEvent,
150
- data: { value?: string; inputText: string }
157
+ data: { value: string; inputText: string }
151
158
  ) => void
152
159
  /**
153
160
  * Callback fired when text input receives focus.
@@ -310,8 +317,11 @@ const allowedProps: AllowedPropKeys = [
310
317
  ]
311
318
 
312
319
  type TimeSelectOptions = {
313
- id?: string
314
- value?: string
320
+ // the ID of this option, ISO date without spaces
321
+ id: string
322
+ // the actual date value
323
+ value: Moment
324
+ // the label shown to the user
315
325
  label: string
316
326
  }
317
327
 
@@ -320,18 +330,32 @@ type TimeSelectState = {
320
330
  * The current value in the input field, not necessarily a valid time
321
331
  */
322
332
  inputValue: string
333
+ /**
334
+ * All possible options. Filtered down because if `allowNonStepInput` is true
335
+ * it'd be 24*60 options and filtered by user input.
336
+ */
323
337
  options: TimeSelectOptions[]
338
+ /**
339
+ * The options shown in the options list.
340
+ */
324
341
  filteredOptions: TimeSelectOptions[]
325
342
  /**
326
343
  * Whether to show the options list.
327
344
  */
328
345
  isShowingOptions: boolean
346
+ /**
347
+ * The highlighted option in the dropdown e.g. by hovering,
348
+ * not necessarily selected
349
+ */
329
350
  highlightedOptionId?: string
351
+ /**
352
+ * The ID of the selected option in the options list dropdown
353
+ */
330
354
  selectedOptionId?: string
331
355
  /**
332
- * Last valid input when nonStepInput is true
356
+ * fire onChange event when the popup closes?
333
357
  */
334
- lastValidInput?: string
358
+ fireChangeOnBlur?: TimeSelectOptions
335
359
  }
336
360
 
337
361
  export type { TimeSelectProps, TimeSelectState, TimeSelectOptions }