@instructure/ui-time-select 11.7.2-snapshot-48 → 11.7.2-snapshot-49

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.
@@ -0,0 +1,645 @@
1
+ /*
2
+ * The MIT License (MIT)
3
+ *
4
+ * Copyright (c) 2015 - present Instructure, Inc.
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ import { Component } from 'react'
26
+ import type { Moment } from 'moment-timezone'
27
+ import { ApplyLocaleContext, Locale, DateTime } from '@instructure/ui-i18n'
28
+ import {
29
+ getInteraction,
30
+ passthroughProps,
31
+ callRenderProp,
32
+ withDeterministicId
33
+ } from '@instructure/ui-react-utils'
34
+ import { Select } from '@instructure/ui-select/latest'
35
+ import * as utils from '@instructure/ui-utils'
36
+
37
+ import type { SelectProps } from '@instructure/ui-select/latest'
38
+ import type {
39
+ TimeSelectProps,
40
+ TimeSelectState,
41
+ TimeSelectOptions
42
+ } from './props'
43
+
44
+ import { allowedProps } from './props'
45
+
46
+ type GetOption = <F extends keyof TimeSelectOptions>(
47
+ field: F,
48
+ value?: TimeSelectOptions[F],
49
+ options?: TimeSelectOptions[]
50
+ ) => TimeSelectOptions | undefined
51
+
52
+ /**
53
+ ---
54
+ category: components
55
+ ---
56
+
57
+ A component used to select a time value.
58
+ **/
59
+ @withDeterministicId()
60
+ class TimeSelect extends Component<TimeSelectProps, TimeSelectState> {
61
+ declare context: React.ContextType<typeof ApplyLocaleContext>
62
+
63
+ static readonly componentId = 'TimeSelect'
64
+ static allowedProps = allowedProps
65
+ static defaultProps = {
66
+ defaultToFirstOption: false,
67
+ format: 'LT', // see https://momentjs.com/docs/#/displaying/
68
+ step: 30,
69
+ isRequired: false,
70
+ isInline: false,
71
+ visibleOptionsCount: 8,
72
+ placement: 'bottom stretch',
73
+ constrain: 'window',
74
+ renderEmptyOption: '---',
75
+ allowNonStepInput: false,
76
+ allowClearingSelection: false
77
+ }
78
+ static contextType = ApplyLocaleContext
79
+
80
+ ref: Select | null = null
81
+
82
+ private readonly _emptyOptionId =
83
+ this.props.deterministicId!('Select-EmptyOption')
84
+
85
+ constructor(props: TimeSelectProps) {
86
+ super(props)
87
+ this.state = this.getInitialState()
88
+ }
89
+
90
+ componentDidMount() {
91
+ // we'll need to recalculate the state because the context value is
92
+ // set at this point (and it might change locale & timezone)
93
+ this.setState(this.getInitialState())
94
+ }
95
+
96
+ focus() {
97
+ this.ref?.focus()
98
+ }
99
+
100
+ blur() {
101
+ this.ref && this.ref.blur()
102
+ }
103
+
104
+ get _select() {
105
+ console.warn(
106
+ '_select property is deprecated and will be removed in v9, please use ref instead'
107
+ )
108
+ return this.ref
109
+ }
110
+
111
+ get isControlled() {
112
+ return typeof this.props.value !== 'undefined'
113
+ }
114
+
115
+ get interaction() {
116
+ return getInteraction({ props: this.props })
117
+ }
118
+
119
+ get focused() {
120
+ return this.ref && this.ref.focused
121
+ }
122
+
123
+ get id() {
124
+ return this.ref && this.ref.id
125
+ }
126
+
127
+ locale() {
128
+ if (this.props.locale) {
129
+ return this.props.locale
130
+ } else if (this.context && this.context.locale) {
131
+ return this.context.locale
132
+ }
133
+ return Locale.browserLocale()
134
+ }
135
+
136
+ timezone() {
137
+ if (this.props.timezone) {
138
+ return this.props.timezone
139
+ } else if (this.context && this.context.timezone) {
140
+ return this.context.timezone
141
+ }
142
+ return DateTime.browserTimeZone()
143
+ }
144
+
145
+ componentDidUpdate(prevProps: TimeSelectProps) {
146
+ if (
147
+ this.props.step !== prevProps.step ||
148
+ this.props.format !== prevProps.format ||
149
+ this.props.locale !== prevProps.locale ||
150
+ this.props.timezone !== prevProps.timezone ||
151
+ this.props.allowNonStepInput !== prevProps.allowNonStepInput
152
+ ) {
153
+ // options change, reset everything
154
+ // when controlled, selection will be preserved
155
+ // when uncontrolled, selection will be lost
156
+ this.setState(this.getInitialState())
157
+ }
158
+ if (this.props.value !== prevProps.value) {
159
+ let newValue: Moment | undefined
160
+ if (this.props.value) {
161
+ newValue = DateTime.parse(
162
+ this.props.value,
163
+ this.locale(),
164
+ this.timezone()
165
+ )
166
+ }
167
+ // value changed
168
+ const initState = this.getInitialState()
169
+ this.setState(initState)
170
+ // options need to be passed because state is not set immediately
171
+ let option
172
+ if (!this.isControlled) {
173
+ // preserve current value when changing from controlled to uncontrolled
174
+ if (prevProps.value) {
175
+ option = this.getOption(
176
+ 'id',
177
+ this.getFormattedId(
178
+ DateTime.parse(prevProps.value, this.locale(), this.timezone())
179
+ )
180
+ )
181
+ }
182
+ } else if (newValue) {
183
+ option = this.getOption(
184
+ 'id',
185
+ this.getFormattedId(newValue),
186
+ initState.options
187
+ )
188
+ }
189
+ const outsideVal = this.props.value ? this.props.value : ''
190
+ // value does not match an existing option
191
+ const date = DateTime.parse(outsideVal, this.locale(), this.timezone())
192
+ let label = ''
193
+ if (date.isValid()) {
194
+ label = this.props.format
195
+ ? date.format(this.props.format)
196
+ : date.toISOString()
197
+ }
198
+ this.setState({
199
+ inputValue: option ? option.label : label,
200
+ selectedOptionId: option ? option.id : undefined
201
+ })
202
+ }
203
+ }
204
+
205
+ getFormattedId(date: Moment) {
206
+ // ISO8601 strings may contain a space. Remove any spaces before using the
207
+ // date as the id.
208
+ return date.toISOString().replace(/\s/g, '')
209
+ }
210
+
211
+ getInitialState(): TimeSelectState {
212
+ const initialOptions = this.generateOptions()
213
+ const initialSelection = this.getInitialOption(initialOptions)
214
+ return {
215
+ inputValue: initialSelection ? initialSelection.label : '',
216
+ options: initialOptions,
217
+ // 288 = 5 min step
218
+ filteredOptions:
219
+ initialOptions.length > 288
220
+ ? initialOptions.filter(
221
+ (opt) => opt.value.minute() % this.props.step! === 0
222
+ )
223
+ : initialOptions,
224
+ isShowingOptions: false,
225
+ highlightedOptionId: initialSelection ? initialSelection.id : undefined,
226
+ selectedOptionId: initialSelection ? initialSelection.id : undefined,
227
+ isInputCleared: false
228
+ }
229
+ }
230
+
231
+ getInitialOption(options: TimeSelectOptions[]) {
232
+ const { value, defaultValue, defaultToFirstOption, format } = this.props
233
+ const initialValue = value || defaultValue
234
+ if (typeof initialValue === 'string') {
235
+ const date = DateTime.parse(initialValue, this.locale(), this.timezone())
236
+ // get option based on value or defaultValue, if provided
237
+ const option = this.getOption('value', date, options)
238
+ if (option) {
239
+ // value matches an existing option
240
+ return option
241
+ }
242
+ // value does not match an existing option
243
+ return {
244
+ id: this.getFormattedId(date),
245
+ label: format ? date.format(format) : date.toISOString(),
246
+ value: date
247
+ } as TimeSelectOptions
248
+ }
249
+ // otherwise, return first option, if desired
250
+ if (defaultToFirstOption) {
251
+ return options[0]
252
+ }
253
+ return undefined
254
+ }
255
+
256
+ getOption: GetOption = (field, value, options = this.state.options) => {
257
+ return options.find((option) => option[field] === value)
258
+ }
259
+
260
+ getBaseDate() {
261
+ let baseDate
262
+ const baseValue = this.props.value || this.props.defaultValue
263
+ if (baseValue) {
264
+ baseDate = DateTime.parse(baseValue, this.locale(), this.timezone())
265
+ } else {
266
+ baseDate = DateTime.now(this.locale(), this.timezone())
267
+ }
268
+ return baseDate.set({ second: 0, millisecond: 0 }).clone()
269
+ }
270
+
271
+ generateOptions(): TimeSelectOptions[] {
272
+ const date = this.getBaseDate()
273
+ const options = []
274
+ const step = this.props.step ? this.props.step : 30
275
+ const maxMinute = this.props.allowNonStepInput ? 60 : 60 / step
276
+ const minuteStep = this.props.allowNonStepInput ? 1 : step
277
+ for (let hour = 0; hour < 24; hour++) {
278
+ for (let minute = 0; minute < maxMinute; minute++) {
279
+ const minutes = minute * minuteStep
280
+ const newDate = date.set({ hour: hour, minute: minutes })
281
+ // store time options
282
+ options.push({
283
+ id: this.getFormattedId(newDate),
284
+ value: newDate.clone(),
285
+ label: this.props.format
286
+ ? newDate.format(this.props.format)
287
+ : newDate.toISOString()
288
+ })
289
+ }
290
+ }
291
+ return options
292
+ }
293
+
294
+ filterOptions(inputValue: string) {
295
+ let inputNoSeconds = inputValue
296
+ // if the input contains seconds disregard them (e.g. if format = LTS)
297
+ if (inputValue.length > 5) {
298
+ // e.g. "5:34:"
299
+ const input = this.parseInputText(inputValue)
300
+ if (input.isValid()) {
301
+ input.set({ second: 0 })
302
+ inputNoSeconds = input.format(this.props.format)
303
+ }
304
+ }
305
+
306
+ if (this.props.allowNonStepInput && inputNoSeconds.length < 3) {
307
+ // could show too many results, show only step values
308
+ return this.state?.options.filter((option: TimeSelectOptions) => {
309
+ return (
310
+ option.label.toLowerCase().startsWith(inputNoSeconds.toLowerCase()) &&
311
+ option.value.minute() % this.props.step! == 0
312
+ )
313
+ })
314
+ }
315
+ return this.state?.options.filter((option: TimeSelectOptions) =>
316
+ option.label.toLowerCase().startsWith(inputNoSeconds.toLowerCase())
317
+ )
318
+ }
319
+
320
+ handleRef = (node: Select) => {
321
+ this.ref = node
322
+ }
323
+
324
+ handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
325
+ this.props.onBlur?.(event)
326
+ }
327
+
328
+ handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
329
+ const value = event.target.value
330
+ const newOptions = this.filterOptions(value)
331
+ if (newOptions?.length == 1) {
332
+ // if there is only 1 option, it will be automatically selected except
333
+ // if in controlled mode (it would commit this change)
334
+ if (!this.isControlled) {
335
+ this.setState({ selectedOptionId: newOptions[0].id })
336
+ }
337
+ this.setState({ fireChangeOnBlur: newOptions[0] })
338
+ } else {
339
+ this.setState({
340
+ fireChangeOnBlur: undefined,
341
+ isInputCleared: this.props.allowClearingSelection! && value === ''
342
+ })
343
+ }
344
+ this.setState({
345
+ inputValue: value,
346
+ filteredOptions: newOptions,
347
+ highlightedOptionId: newOptions.length > 0 ? newOptions[0].id : undefined,
348
+ isShowingOptions: true
349
+ })
350
+ if (!this.state.isShowingOptions) {
351
+ this.props.onShowOptions?.(event)
352
+ }
353
+ const inputAsDate = this.parseInputText(value)
354
+ this.props.onInputChange?.(
355
+ event,
356
+ value,
357
+ inputAsDate.isValid() ? inputAsDate.toISOString() : undefined
358
+ )
359
+ }
360
+
361
+ onKeyDown = (event: React.KeyboardEvent<any>) => {
362
+ const input = this.parseInputText(this.state.inputValue)
363
+ if (
364
+ event.key === 'Enter' &&
365
+ this.props.allowNonStepInput &&
366
+ input.isValid()
367
+ ) {
368
+ this.setState(() => ({
369
+ isShowingOptions: false,
370
+ highlightedOptionId: undefined
371
+ }))
372
+ // others are set in handleBlurOrEsc
373
+ }
374
+ this.props.onKeyDown?.(event)
375
+ }
376
+
377
+ handleShowOptions = (event: React.SyntheticEvent) => {
378
+ this.setState({
379
+ isShowingOptions: true,
380
+ highlightedOptionId: this.state.selectedOptionId
381
+ })
382
+ this.props.onShowOptions?.(event)
383
+
384
+ if (event.type.startsWith('key')) {
385
+ const keyboardEvent = event as React.KeyboardEvent
386
+ const children = this.state.filteredOptions
387
+ if (
388
+ !this.state.inputValue &&
389
+ children.length > 0 &&
390
+ !this.props.allowClearingSelection
391
+ ) {
392
+ const optionId =
393
+ keyboardEvent.key === 'ArrowDown'
394
+ ? children[0].id
395
+ : keyboardEvent.key === 'ArrowUp'
396
+ ? children[children.length - 1].id
397
+ : undefined
398
+ optionId &&
399
+ this.setState({
400
+ highlightedOptionId: optionId
401
+ })
402
+ }
403
+ }
404
+ }
405
+
406
+ // Called when the input is blurred (=when clicking outside, tabbing away),
407
+ // when pressing ESC. NOT called when an item is selected via Enter/click,
408
+ // (but in this case it will be called later when the input is blurred.)
409
+ handleBlurOrEsc: SelectProps['onRequestHideOptions'] = (event) => {
410
+ const { selectedOptionId, inputValue, isInputCleared } = this.state
411
+ let defaultValue = ''
412
+ if (this.props.defaultValue) {
413
+ const date = DateTime.parse(
414
+ this.props.defaultValue,
415
+ this.locale(),
416
+ this.timezone()
417
+ )
418
+ defaultValue = this.props.format
419
+ ? date.format(this.props.format)
420
+ : date.toISOString()
421
+ }
422
+ const selectedOption = this.getOption('id', selectedOptionId)
423
+ let newInputValue = defaultValue
424
+ // if input was completely cleared, ensure it stays clear
425
+ // e.g. defaultValue defined, but no selection yet made
426
+ if (inputValue === '' && this.props.allowClearingSelection) {
427
+ newInputValue = ''
428
+ } else if (selectedOption) {
429
+ // If there is a selected option use its value in the input field.
430
+ newInputValue = selectedOption.label
431
+ } else if (this.props.value) {
432
+ // If controlled and input is cleared and blurred after the first render, it should revert to value
433
+ const date = DateTime.parse(
434
+ this.props.value,
435
+ this.locale(),
436
+ this.timezone()
437
+ )
438
+ newInputValue = this.props.format
439
+ ? date.format(this.props.format)
440
+ : date.toISOString()
441
+ }
442
+ this.setState(() => ({
443
+ isShowingOptions: false,
444
+ highlightedOptionId: undefined,
445
+ inputValue: newInputValue,
446
+ filteredOptions: this.filterOptions('')
447
+ }))
448
+ if (this.state.fireChangeOnBlur && (event as any).key !== 'Escape') {
449
+ this.setState(() => ({ fireChangeOnBlur: undefined }))
450
+ this.props.onChange?.(event, {
451
+ value: this.state.fireChangeOnBlur.value.toISOString(),
452
+ inputText: this.state.fireChangeOnBlur.label
453
+ })
454
+ } else if (
455
+ isInputCleared &&
456
+ (event as any).key !== 'Escape' &&
457
+ this.props.allowClearingSelection
458
+ ) {
459
+ this.setState(() => ({ isInputCleared: false }))
460
+ this.props.onChange?.(event, {
461
+ value: '',
462
+ inputText: ''
463
+ })
464
+ }
465
+ // TODO only fire this if handleSelectOption was not called before.
466
+ this.props.onHideOptions?.(event)
467
+ }
468
+
469
+ // Called when an option is selected via mouse click or Enter.
470
+ handleSelectOption: SelectProps['onRequestSelectOption'] = (event, data) => {
471
+ if (data.id === this._emptyOptionId) {
472
+ this.setState({ isShowingOptions: false })
473
+ return
474
+ }
475
+ const selectedOption = this.getOption('id', data.id)
476
+ let newInputValue: string
477
+ const currentSelectedOptionId = this.state.selectedOptionId
478
+
479
+ // Focus needs to be reapplied to input
480
+ // after selecting an item to make sure VoiceOver behaves correctly on iOS
481
+ if (utils.isAndroidOrIOS()) {
482
+ this.blur()
483
+ this.focus()
484
+ }
485
+
486
+ if (this.isControlled) {
487
+ // in controlled mode we leave to the user to set the value of the
488
+ // component e.g. in the onChange event handler.
489
+ // This is accomplished by not setting a selectedOptionId
490
+ const prev = this.getOption('id', this.state.selectedOptionId)
491
+ newInputValue = prev ? prev.label : ''
492
+ this.setState({
493
+ isShowingOptions: false
494
+ })
495
+ } else {
496
+ newInputValue = selectedOption!.label
497
+ this.setState({
498
+ isShowingOptions: false,
499
+ selectedOptionId: data.id,
500
+ inputValue: newInputValue
501
+ })
502
+ }
503
+ if (data.id !== currentSelectedOptionId) {
504
+ this.props.onChange?.(event, {
505
+ value: selectedOption!.value.toISOString(),
506
+ inputText: newInputValue
507
+ })
508
+ }
509
+ this.props.onHideOptions?.(event)
510
+ }
511
+
512
+ handleHighlightOption: SelectProps['onRequestHighlightOption'] = (
513
+ _event,
514
+ data
515
+ ) => {
516
+ if (data.id === this._emptyOptionId) return
517
+
518
+ this.setState((state) => ({
519
+ highlightedOptionId: data.id,
520
+ inputValue: state.inputValue
521
+ }))
522
+ }
523
+
524
+ renderOptions() {
525
+ const { filteredOptions, highlightedOptionId, selectedOptionId } =
526
+ this.state
527
+
528
+ if (filteredOptions.length < 1) {
529
+ return this.renderEmptyOption()
530
+ }
531
+
532
+ return filteredOptions.map((option: TimeSelectOptions) => {
533
+ const { id, label } = option
534
+ return (
535
+ <Select.Option
536
+ id={id}
537
+ key={id}
538
+ isHighlighted={id === highlightedOptionId}
539
+ isSelected={id === selectedOptionId}
540
+ >
541
+ {label}
542
+ </Select.Option>
543
+ )
544
+ })
545
+ }
546
+
547
+ renderEmptyOption() {
548
+ return (
549
+ <Select.Option
550
+ id={this._emptyOptionId}
551
+ isHighlighted={false}
552
+ isSelected={false}
553
+ >
554
+ {callRenderProp(this.props.renderEmptyOption)}
555
+ </Select.Option>
556
+ )
557
+ }
558
+
559
+ parseInputText = (inputValue: string) => {
560
+ const input = DateTime.parse(
561
+ inputValue,
562
+ this.locale(),
563
+ this.timezone(),
564
+ [this.props.format!],
565
+ true
566
+ )
567
+ const baseDate = this.getBaseDate()
568
+ input.year(baseDate.year())
569
+ input.month(baseDate.month())
570
+ input.date(baseDate.date())
571
+ return input
572
+ }
573
+
574
+ render() {
575
+ const {
576
+ value,
577
+ defaultValue,
578
+ placeholder,
579
+ renderLabel,
580
+ inputRef,
581
+ id,
582
+ listRef,
583
+ renderBeforeInput,
584
+ renderAfterInput,
585
+ isRequired,
586
+ isInline,
587
+ width,
588
+ format,
589
+ step,
590
+ optionsMaxWidth,
591
+ visibleOptionsCount,
592
+ messages,
593
+ placement,
594
+ constrain,
595
+ onFocus,
596
+ onShowOptions,
597
+ onHideOptions,
598
+ onInputChange,
599
+ onKeyDown,
600
+ mountNode,
601
+ ...rest
602
+ } = this.props
603
+
604
+ const { inputValue, isShowingOptions } = this.state
605
+ return (
606
+ <Select
607
+ mountNode={mountNode}
608
+ renderLabel={renderLabel}
609
+ inputValue={inputValue}
610
+ interaction={this.interaction}
611
+ placeholder={placeholder}
612
+ id={id}
613
+ onFocus={onFocus}
614
+ onBlur={this.handleBlur}
615
+ ref={this.handleRef}
616
+ inputRef={inputRef}
617
+ listRef={listRef}
618
+ isRequired={isRequired}
619
+ isInline={isInline}
620
+ width={width}
621
+ optionsMaxWidth={optionsMaxWidth}
622
+ visibleOptionsCount={visibleOptionsCount}
623
+ messages={messages}
624
+ placement={placement}
625
+ constrain={constrain}
626
+ renderBeforeInput={renderBeforeInput}
627
+ renderAfterInput={renderAfterInput}
628
+ isShowingOptions={isShowingOptions}
629
+ onRequestShowOptions={this.handleShowOptions}
630
+ onRequestHideOptions={this.handleBlurOrEsc}
631
+ onRequestHighlightOption={this.handleHighlightOption}
632
+ onRequestSelectOption={this.handleSelectOption}
633
+ onInputChange={this.handleInputChange}
634
+ onKeyDown={this.onKeyDown}
635
+ {...passthroughProps(rest)}
636
+ data-cid="TimeSelect"
637
+ >
638
+ {isShowingOptions && this.renderOptions()}
639
+ </Select>
640
+ )
641
+ }
642
+ }
643
+
644
+ export { TimeSelect }
645
+ export default TimeSelect