@instructure/ui-time-select 8.34.1-snapshot-1 → 8.35.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.
@@ -23,8 +23,7 @@
23
23
  */
24
24
 
25
25
  import React, { Component } from 'react'
26
- import { Moment } from 'moment-timezone'
27
-
26
+ import type { Moment } from 'moment-timezone'
28
27
  import { ApplyLocaleContext, Locale, DateTime } from '@instructure/ui-i18n'
29
28
  import {
30
29
  getInteraction,
@@ -32,7 +31,6 @@ import {
32
31
  callRenderProp,
33
32
  withDeterministicId
34
33
  } from '@instructure/ui-react-utils'
35
-
36
34
  import { testable } from '@instructure/ui-testable'
37
35
  import { Select } from '@instructure/ui-select'
38
36
 
@@ -62,11 +60,11 @@ A component used to select a time value.
62
60
  @withDeterministicId()
63
61
  @testable()
64
62
  class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
65
- static readonly componentId = 'TimeSelect'
63
+ declare context: React.ContextType<typeof ApplyLocaleContext>
66
64
 
65
+ static readonly componentId = 'TimeSelect'
67
66
  static allowedProps = allowedProps
68
67
  static propTypes = propTypes
69
-
70
68
  static defaultProps = {
71
69
  defaultToFirstOption: false,
72
70
  format: 'LT', // see https://momentjs.com/docs/#/displaying/
@@ -76,9 +74,9 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
76
74
  visibleOptionsCount: 8,
77
75
  placement: 'bottom stretch',
78
76
  constrain: 'window',
79
- renderEmptyOption: '---'
77
+ renderEmptyOption: '---',
78
+ allowNonStepInput: false
80
79
  }
81
- declare context: React.ContextType<typeof ApplyLocaleContext>
82
80
  static contextType = ApplyLocaleContext
83
81
 
84
82
  ref: Select | null = null
@@ -153,7 +151,6 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
153
151
  // when uncontrolled, selection will be lost
154
152
  this.setState(this.getInitialState())
155
153
  }
156
-
157
154
  if (this.props.value !== prevProps.value) {
158
155
  const normalizedValue = this.normalizeISOTime(this.props.value)
159
156
  // value changed
@@ -168,9 +165,12 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
168
165
  const outsideVal = this.props.value ? this.props.value : ''
169
166
  // value does not match an existing option
170
167
  const date = DateTime.parse(outsideVal, this.locale(), this.timezone())
171
- const label = this.props.format
172
- ? date.format(this.props.format)
173
- : date.toISOString()
168
+ let label = ''
169
+ if (date.isValid()) {
170
+ label = this.props.format
171
+ ? date.format(this.props.format)
172
+ : date.toISOString()
173
+ }
174
174
  this.setState({
175
175
  inputValue: option ? option.label : label,
176
176
  selectedOptionId: option ? option.id : undefined
@@ -330,17 +330,21 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
330
330
  handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
331
331
  const value = event.target.value
332
332
  const newOptions = this.filterOptions(value)
333
-
334
333
  this.setState({
335
334
  inputValue: value,
336
335
  filteredOptions: newOptions,
337
336
  highlightedOptionId: newOptions.length > 0 ? newOptions[0].id : undefined,
338
337
  isShowingOptions: true
339
338
  })
340
-
341
339
  if (!this.state.isShowingOptions) {
342
340
  this.props.onShowOptions?.(event)
343
341
  }
342
+ const inputAsDate = this.parseInputText(value)
343
+ this.props.onInputChange?.(
344
+ event,
345
+ value,
346
+ inputAsDate.isValid() ? inputAsDate.toISOString() : undefined
347
+ )
344
348
  }
345
349
 
346
350
  handleShowOptions = (event: React.SyntheticEvent) => {
@@ -351,31 +355,42 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
351
355
  this.props.onShowOptions?.(event)
352
356
  }
353
357
 
354
- handleHideOptions: SelectProps['onRequestHideOptions'] = (
355
- event: React.SyntheticEvent
356
- ) => {
357
- const { selectedOptionId } = this.state
358
- const option = this.getOption('id', selectedOptionId)
359
- let prevValue = ''
360
-
358
+ handleHideOptions: SelectProps['onRequestHideOptions'] = (event) => {
359
+ const input = this.parseInputText(this.state.inputValue)
360
+ let defaultValue = ''
361
361
  if (this.props.defaultValue) {
362
362
  const date = DateTime.parse(
363
363
  this.props.defaultValue,
364
364
  this.locale(),
365
365
  this.timezone()
366
366
  )
367
- prevValue = this.props.format
367
+ defaultValue = this.props.format
368
368
  ? date.format(this.props.format)
369
369
  : date.toISOString()
370
370
  }
371
-
372
- this.setState(() => ({
373
- isShowingOptions: false,
374
- highlightedOptionId: undefined,
375
- inputValue: selectedOptionId ? option!.label : prevValue,
376
- filteredOptions: this.filterOptions(''),
377
- ...this.matchValue()
378
- }))
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
+ }))
393
+ }
379
394
  this.props.onHideOptions?.(event)
380
395
  }
381
396
 
@@ -423,6 +438,9 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
423
438
  }
424
439
 
425
440
  if (id !== this.state.selectedOptionId) {
441
+ this.setState({
442
+ lastValidInput: newInputValue
443
+ })
426
444
  this.props.onChange?.(event, {
427
445
  value: option.value,
428
446
  inputText: newInputValue
@@ -466,6 +484,46 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
466
484
  )
467
485
  }
468
486
 
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
+ parseInputText = (inputValue: string) => {
513
+ const input = DateTime.parse(
514
+ inputValue,
515
+ this.locale(),
516
+ this.timezone(),
517
+ [this.props.format!],
518
+ true
519
+ )
520
+ const baseDate = this.getBaseDate()
521
+ input.year(baseDate.year())
522
+ input.month(baseDate.month())
523
+ input.date(baseDate.date())
524
+ return input
525
+ }
526
+
469
527
  render() {
470
528
  const {
471
529
  value,
@@ -490,6 +548,8 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
490
548
  onFocus,
491
549
  onShowOptions,
492
550
  onHideOptions,
551
+ onInputChange,
552
+ onKeyDown,
493
553
  mountNode,
494
554
  ...rest
495
555
  } = this.props
@@ -524,6 +584,7 @@ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
524
584
  onRequestHighlightOption={this.handleHighlightOption}
525
585
  onRequestSelectOption={this.handleSelectOption}
526
586
  onInputChange={this.handleInputChange}
587
+ onKeyDown={this.onKeyDown}
527
588
  {...passthroughProps(rest)}
528
589
  >
529
590
  {isShowingOptions && this.renderOptions()}
@@ -211,6 +211,32 @@ type TimeSelectOwnProps = {
211
211
  * property.
212
212
  */
213
213
  timezone?: string
214
+ /**
215
+ * Whether to allow the user to enter non-step divisible values in the input field.
216
+ * Note that even if this is set to `false` one can enter non-step divisible values programatically.
217
+ * The user will need to enter the value exactly (except for lower/uppercase) as specified by the `format` prop for
218
+ * it to be accepted.
219
+ *
220
+ * Default is `false`
221
+ */
222
+ allowNonStepInput?: boolean
223
+ /**
224
+ * Callback fired when text input value changes.
225
+ */
226
+ onInputChange?: (
227
+ /**
228
+ * The raw HTML input event
229
+ */
230
+ event: React.ChangeEvent<HTMLInputElement>,
231
+ /**
232
+ * The text value in the input field.
233
+ */
234
+ value: string,
235
+ /**
236
+ * Current value as ISO datetime string, undefined it its a non-valid value.
237
+ */
238
+ valueAsISOString?: string
239
+ ) => void
214
240
  }
215
241
 
216
242
  const propTypes: PropValidators<PropKeys> = {
@@ -243,7 +269,9 @@ const propTypes: PropValidators<PropKeys> = {
243
269
  renderBeforeInput: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
244
270
  renderAfterInput: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
245
271
  locale: PropTypes.string,
246
- timezone: PropTypes.string
272
+ timezone: PropTypes.string,
273
+ allowNonStepInput: PropTypes.bool,
274
+ onInputChange: PropTypes.func
247
275
  }
248
276
 
249
277
  const allowedProps: AllowedPropKeys = [
@@ -276,7 +304,9 @@ const allowedProps: AllowedPropKeys = [
276
304
  'renderBeforeInput',
277
305
  'renderAfterInput',
278
306
  'locale',
279
- 'timezone'
307
+ 'timezone',
308
+ 'allowNonStepInput',
309
+ 'onInputChange'
280
310
  ]
281
311
 
282
312
  type TimeSelectOptions = {
@@ -286,12 +316,22 @@ type TimeSelectOptions = {
286
316
  }
287
317
 
288
318
  type TimeSelectState = {
319
+ /**
320
+ * The current value in the input field, not necessarily a valid time
321
+ */
289
322
  inputValue: string
290
323
  options: TimeSelectOptions[]
291
324
  filteredOptions: TimeSelectOptions[]
325
+ /**
326
+ * Whether to show the options list.
327
+ */
292
328
  isShowingOptions: boolean
293
329
  highlightedOptionId?: string
294
330
  selectedOptionId?: string
331
+ /**
332
+ * Last valid input when nonStepInput is true
333
+ */
334
+ lastValidInput?: string
295
335
  }
296
336
 
297
337
  export type { TimeSelectProps, TimeSelectState, TimeSelectOptions }
@@ -15,6 +15,7 @@
15
15
  { "path": "../ui-select/tsconfig.build.json" },
16
16
  { "path": "../ui-test-locator/tsconfig.build.json" },
17
17
  { "path": "../ui-testable/tsconfig.build.json" },
18
+ { "path": "../ui-utils/tsconfig.build.json" },
18
19
  { "path": "../ui-babel-preset/tsconfig.build.json" },
19
20
  { "path": "../ui-test-utils/tsconfig.build.json" },
20
21
  { "path": "../shared-types/tsconfig.build.json" }