@instructure/ui-select 11.7.2-snapshot-47 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +12 -2
  2. package/es/Select/v2/Group/index.js +47 -0
  3. package/es/Select/v2/Group/props.js +26 -0
  4. package/es/Select/v2/Option/index.js +51 -0
  5. package/es/Select/v2/Option/props.js +26 -0
  6. package/es/Select/v2/index.js +704 -0
  7. package/es/Select/v2/props.js +35 -0
  8. package/es/Select/v2/styles.js +41 -0
  9. package/es/exports/b.js +26 -0
  10. package/lib/Select/v2/Group/index.js +53 -0
  11. package/lib/Select/v2/Group/props.js +31 -0
  12. package/lib/Select/v2/Option/index.js +57 -0
  13. package/lib/Select/v2/Option/props.js +31 -0
  14. package/lib/Select/v2/index.js +714 -0
  15. package/lib/Select/v2/props.js +40 -0
  16. package/lib/Select/v2/styles.js +47 -0
  17. package/lib/exports/b.js +26 -0
  18. package/package.json +29 -29
  19. package/src/Select/v2/Group/index.tsx +52 -0
  20. package/src/Select/v2/Group/props.ts +48 -0
  21. package/src/Select/v2/Option/index.tsx +56 -0
  22. package/src/Select/v2/Option/props.ts +82 -0
  23. package/src/Select/v2/README.md +1342 -0
  24. package/src/Select/v2/index.tsx +897 -0
  25. package/src/Select/v2/props.ts +333 -0
  26. package/src/Select/v2/styles.ts +48 -0
  27. package/src/exports/b.ts +31 -0
  28. package/tsconfig.build.tsbuildinfo +1 -1
  29. package/types/Select/v2/Group/index.d.ts +21 -0
  30. package/types/Select/v2/Group/index.d.ts.map +1 -0
  31. package/types/Select/v2/Group/props.d.ts +19 -0
  32. package/types/Select/v2/Group/props.d.ts.map +1 -0
  33. package/types/Select/v2/Option/index.d.ts +28 -0
  34. package/types/Select/v2/Option/index.d.ts.map +1 -0
  35. package/types/Select/v2/Option/props.d.ts +43 -0
  36. package/types/Select/v2/Option/props.d.ts.map +1 -0
  37. package/types/Select/v2/index.d.ts +138 -0
  38. package/types/Select/v2/index.d.ts.map +1 -0
  39. package/types/Select/v2/props.d.ts +206 -0
  40. package/types/Select/v2/props.d.ts.map +1 -0
  41. package/types/Select/v2/styles.d.ts +12 -0
  42. package/types/Select/v2/styles.d.ts.map +1 -0
  43. package/types/exports/b.d.ts +7 -0
  44. package/types/exports/b.d.ts.map +1 -0
@@ -0,0 +1,897 @@
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 { ComponentElement, Children, Component, memo, ReactNode } from 'react'
26
+
27
+ import * as utils from '@instructure/ui-utils'
28
+ import { combineDataCid } from '@instructure/ui-utils'
29
+ import {
30
+ matchComponentTypes,
31
+ omitProps,
32
+ getInteraction,
33
+ withDeterministicId
34
+ } from '@instructure/ui-react-utils'
35
+ import {
36
+ getBoundingClientRect,
37
+ isActiveElement
38
+ } from '@instructure/ui-dom-utils'
39
+
40
+ import { View } from '@instructure/ui-view/latest'
41
+ import { Selectable } from '@instructure/ui-selectable'
42
+ import { Popover } from '@instructure/ui-popover/latest'
43
+ import { TextInput } from '@instructure/ui-text-input/latest'
44
+ import { Options } from '@instructure/ui-options/latest'
45
+ import {
46
+ ChevronDownInstUIIcon,
47
+ ChevronUpInstUIIcon,
48
+ CheckInstUIIcon
49
+ } from '@instructure/ui-icons'
50
+
51
+ import type { ViewProps } from '@instructure/ui-view/latest'
52
+ import type { TextInputProps } from '@instructure/ui-text-input/latest'
53
+ import type {
54
+ OptionsItemProps,
55
+ OptionsSeparatorProps,
56
+ OptionsItemRenderProps
57
+ } from '@instructure/ui-options/latest'
58
+ import type {
59
+ SelectableProps,
60
+ SelectableRender
61
+ } from '@instructure/ui-selectable'
62
+
63
+ import { withStyle, BorderWidth } from '@instructure/emotion'
64
+
65
+ import generateStyle from './styles'
66
+
67
+ import { Group } from './Group'
68
+ import type { SelectGroupProps } from './Group/props'
69
+ import { Option } from './Option'
70
+ import type { SelectOptionProps, RenderSelectOptionLabel } from './Option/props'
71
+
72
+ import type { SelectProps } from './props'
73
+ import { allowedProps } from './props'
74
+ import { Renderable } from '@instructure/shared-types'
75
+
76
+ const selectSizeToIconSize: Record<
77
+ NonNullable<SelectProps['size']>,
78
+ 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
79
+ > = {
80
+ small: 'sm',
81
+ medium: 'md',
82
+ large: 'lg'
83
+ }
84
+
85
+ type GroupChild = ComponentElement<SelectGroupProps, Group>
86
+ type OptionChild = ComponentElement<SelectOptionProps, Option>
87
+ type SelectChildren = (GroupChild | OptionChild)[]
88
+
89
+ type MemoedOptionProps = React.PropsWithChildren<{
90
+ selectOption: OptionChild
91
+ optionsItemProps: OptionsItemProps
92
+ }>
93
+
94
+ // This memoed Option component is used to prevent unnecessary re-renders of
95
+ // Options.Item when the Select component is re-rendered. This is necessary
96
+ // because the Select component is re-rendered on every prop change of the <Select.Option>
97
+ // and with a large amount of options, this can cause a lot of unnecessary re-renders.
98
+ const MemoedOption = memo(
99
+ function Opt(props: MemoedOptionProps) {
100
+ const { optionsItemProps, children } = props
101
+
102
+ return (
103
+ // The main <Options> that renders this is always an "ul"
104
+ <Options.Item as="li" {...optionsItemProps}>
105
+ {children}
106
+ </Options.Item>
107
+ )
108
+ },
109
+ // This is a custom equality function that checks if the props of the
110
+ // <Select.Option> have changed. If they haven't, then the Options.Item
111
+ // doesn't need to be re-rendered.
112
+ (prevProps, nextProps) => {
113
+ return (
114
+ prevProps.selectOption.props.isHighlighted ===
115
+ nextProps.selectOption.props.isHighlighted &&
116
+ prevProps.selectOption.props.isSelected ===
117
+ nextProps.selectOption.props.isSelected &&
118
+ prevProps.selectOption.props.isDisabled ===
119
+ nextProps.selectOption.props.isDisabled &&
120
+ prevProps.selectOption.props.children ===
121
+ nextProps.selectOption.props.children &&
122
+ prevProps.selectOption.props.id === nextProps.selectOption.props.id &&
123
+ prevProps.selectOption.props.renderBeforeLabel ===
124
+ nextProps.selectOption.props.renderBeforeLabel &&
125
+ prevProps.selectOption.props.renderAfterLabel ===
126
+ nextProps.selectOption.props.renderAfterLabel &&
127
+ prevProps.children === nextProps.children
128
+ )
129
+ }
130
+ )
131
+ // This is needed so the propTypes in <Options> check are correct
132
+ MemoedOption.displayName = 'Item'
133
+
134
+ /**
135
+ ---
136
+ category: components
137
+ tags: autocomplete, typeahead, combobox, dropdown, search, form
138
+ ---
139
+ **/
140
+ @withDeterministicId()
141
+ @withStyle(generateStyle)
142
+ class Select extends Component<SelectProps> {
143
+ static readonly componentId = 'Select'
144
+ private readonly SCROLL_TOLERANCE = 0.5
145
+
146
+ static allowedProps = allowedProps
147
+
148
+ static defaultProps = {
149
+ inputValue: '',
150
+ isShowingOptions: false,
151
+ size: 'medium',
152
+ // Leave interaction default undefined so that `disabled` and `readOnly` can also be supplied
153
+ interaction: undefined,
154
+ isRequired: false,
155
+ isInline: false,
156
+ visibleOptionsCount: 8,
157
+ placement: 'bottom stretch',
158
+ constrain: 'window',
159
+ shouldNotWrap: false,
160
+ scrollToHighlightedOption: true,
161
+ isOptionContentAppliedToInput: false
162
+ }
163
+
164
+ static Option = Option
165
+ static Group = Group
166
+
167
+ componentDidMount() {
168
+ this.props.makeStyles?.()
169
+ }
170
+
171
+ componentDidUpdate() {
172
+ this.props.makeStyles?.()
173
+
174
+ if (this.props.scrollToHighlightedOption) {
175
+ // scroll option into view if needed
176
+ requestAnimationFrame(() => this.scrollToOption(this.highlightedOptionId))
177
+ }
178
+ }
179
+
180
+ state = {
181
+ hasInputRef: false
182
+ }
183
+ ref: HTMLSpanElement | null = null
184
+ _input: HTMLInputElement | null = null
185
+ private _defaultId = this.props.deterministicId!()
186
+ private _inputContainer: HTMLSpanElement | null = null
187
+ private _listView: Element | null = null
188
+ // temporarily stores actionable options
189
+ private _optionIds: string[] = []
190
+ // best guess for first calculation of list height
191
+ private _optionHeight = 36
192
+
193
+ focus() {
194
+ this._input && this._input.focus()
195
+ }
196
+
197
+ blur() {
198
+ this._input && this._input.blur()
199
+ }
200
+
201
+ get childrenArray() {
202
+ return Children.toArray(this.props.children) as SelectChildren
203
+ }
204
+
205
+ getGroupChildrenArray(group: GroupChild) {
206
+ return Children.toArray(group.props.children) as OptionChild[]
207
+ }
208
+
209
+ get focused() {
210
+ return this._input ? isActiveElement(this._input) : false
211
+ }
212
+
213
+ get id() {
214
+ return this.props.id || this._defaultId
215
+ }
216
+
217
+ get width() {
218
+ return this._inputContainer ? this._inputContainer.offsetWidth : undefined
219
+ }
220
+
221
+ get interaction() {
222
+ return getInteraction({ props: this.props })
223
+ }
224
+
225
+ get highlightedOptionId(): string | undefined {
226
+ let highlightedOptionId: string | undefined
227
+
228
+ this.childrenArray.forEach((child) => {
229
+ if (matchComponentTypes<GroupChild>(child, [Group])) {
230
+ // group found
231
+ this.getGroupChildrenArray(child).forEach((option) => {
232
+ // check options in group
233
+ if (option.props.isHighlighted) {
234
+ highlightedOptionId = option.props.id
235
+ }
236
+ })
237
+ } else {
238
+ // ungrouped option found
239
+ if (child.props.isHighlighted) {
240
+ highlightedOptionId = child.props.id
241
+ }
242
+ }
243
+ })
244
+
245
+ return highlightedOptionId
246
+ }
247
+
248
+ get selectedOptionId() {
249
+ const selectedOptionId: string[] = []
250
+
251
+ this.childrenArray.forEach((child) => {
252
+ if (matchComponentTypes<GroupChild>(child, [Group])) {
253
+ // group found
254
+ this.getGroupChildrenArray(child).forEach((option) => {
255
+ // check options in group
256
+ if (option.props.isSelected) {
257
+ selectedOptionId.push(option.props.id)
258
+ }
259
+ })
260
+ } else {
261
+ // ungrouped option found
262
+ if (child.props.isSelected) {
263
+ selectedOptionId.push(child.props.id)
264
+ }
265
+ }
266
+ })
267
+
268
+ if (selectedOptionId.length === 1) {
269
+ return selectedOptionId[0]
270
+ }
271
+ if (selectedOptionId.length === 0) {
272
+ return undefined
273
+ }
274
+ return selectedOptionId
275
+ }
276
+
277
+ handleInputRef = (node: HTMLInputElement | null) => {
278
+ // ensures list is positioned with respect to input if list is open on mount
279
+ if (!this.state.hasInputRef) {
280
+ this.setState({ hasInputRef: true })
281
+ }
282
+ this._input = node
283
+ this.props.inputRef?.(node)
284
+ }
285
+
286
+ handleListRef = (node: HTMLUListElement | null) => {
287
+ this.props.listRef?.(node)
288
+
289
+ // store option height to calculate list maxHeight
290
+ if (node && node.querySelector('[role="option"]')) {
291
+ this._optionHeight = (
292
+ node.querySelector('[role="option"]') as HTMLElement
293
+ ).offsetHeight
294
+ }
295
+ }
296
+
297
+ handleInputContainerRef = (node: HTMLSpanElement | null) => {
298
+ this._inputContainer = node
299
+ }
300
+
301
+ scrollToOption(id?: string) {
302
+ if (!this._listView || !id) return
303
+ const option = this._listView.querySelector(`[id="${CSS.escape(id)}"]`)
304
+ if (!option) return
305
+
306
+ const listItem = option.parentNode
307
+ const parentTop = getBoundingClientRect(this._listView).top
308
+ const elemTop = getBoundingClientRect(listItem).top
309
+ const parentBottom = parentTop + this._listView.clientHeight
310
+ const elemBottom =
311
+ elemTop + (listItem ? (listItem as Element).clientHeight : 0)
312
+
313
+ if (elemBottom > parentBottom) {
314
+ this._listView.scrollTop += elemBottom - parentBottom
315
+ } else if (elemTop < parentTop) {
316
+ this._listView.scrollTop -= parentTop - elemTop
317
+ }
318
+ }
319
+
320
+ highlightOption(event: React.KeyboardEvent | React.MouseEvent, id: string) {
321
+ const { onRequestHighlightOption } = this.props
322
+ if (id) {
323
+ onRequestHighlightOption?.(event, { id })
324
+ }
325
+ }
326
+
327
+ getEventHandlers(): Partial<SelectableProps> {
328
+ const {
329
+ isShowingOptions,
330
+ onRequestShowOptions,
331
+ onRequestHideOptions,
332
+ onRequestSelectOption
333
+ } = this.props
334
+
335
+ return this.interaction === 'enabled'
336
+ ? {
337
+ onRequestShowOptions: (event) => {
338
+ onRequestShowOptions?.(event)
339
+ const selectedOptionId = this.selectedOptionId
340
+
341
+ if (selectedOptionId && !Array.isArray(selectedOptionId)) {
342
+ // highlight selected option on show
343
+ this.highlightOption(event, selectedOptionId)
344
+ }
345
+ },
346
+ onRequestHideOptions: (event) => {
347
+ onRequestHideOptions?.(event)
348
+ },
349
+ onRequestHighlightOption: (
350
+ event,
351
+ { id, direction }: { id?: string; direction?: number }
352
+ ) => {
353
+ if (!isShowingOptions) return
354
+
355
+ const highlightedOptionId = this.highlightedOptionId
356
+ // if id exists, use that
357
+ let highlightId = this._optionIds.indexOf(id!) > -1 ? id : undefined
358
+ if (!highlightId) {
359
+ if (!highlightedOptionId) {
360
+ // nothing highlighted yet, highlight first option
361
+ highlightId = this._optionIds[0]
362
+ } else {
363
+ // find next id based on direction
364
+ const index = this._optionIds.indexOf(highlightedOptionId)
365
+ highlightId =
366
+ index > -1 ? this._optionIds[index + direction!] : undefined
367
+ }
368
+ }
369
+ if (highlightId) {
370
+ // only highlight if id exists as a valid option
371
+ this.highlightOption(event, highlightId)
372
+ }
373
+ },
374
+ onRequestHighlightFirstOption: (event) => {
375
+ this.highlightOption(event, this._optionIds[0])
376
+ },
377
+ onRequestHighlightLastOption: (event) => {
378
+ this.highlightOption(
379
+ event,
380
+ this._optionIds[this._optionIds.length - 1]
381
+ )
382
+ },
383
+ onRequestSelectOption: (event, { id }) => {
384
+ if (id && this._optionIds.indexOf(id) !== -1) {
385
+ // only select if id exists as a valid option
386
+ onRequestSelectOption?.(event, { id })
387
+ }
388
+ }
389
+ }
390
+ : {}
391
+ }
392
+
393
+ renderOption(
394
+ option: OptionChild,
395
+ data: Pick<SelectableRender, 'getOptionProps' | 'getDisabledOptionProps'>
396
+ ) {
397
+ const { getOptionProps, getDisabledOptionProps } = data
398
+ const {
399
+ id,
400
+ isDisabled,
401
+ isHighlighted,
402
+ isSelected,
403
+ renderBeforeLabel,
404
+ renderAfterLabel,
405
+ children
406
+ } = option.props
407
+
408
+ const getRenderOptionLabel = (
409
+ renderOptionLabel: RenderSelectOptionLabel
410
+ ):
411
+ | React.ReactNode
412
+ | ((_args: OptionsItemRenderProps) => React.ReactNode) => {
413
+ return typeof renderOptionLabel === 'function' &&
414
+ !renderOptionLabel?.prototype?.isReactComponent
415
+ ? (renderOptionLabel as any).bind(null, {
416
+ id,
417
+ isDisabled,
418
+ isSelected,
419
+ isHighlighted,
420
+ children
421
+ })
422
+ : (renderOptionLabel as React.ReactNode)
423
+ }
424
+
425
+ const { isOptionContentAppliedToInput, size = 'medium' } = this.props
426
+ const iconSize = selectSizeToIconSize[size]
427
+ const checkIcon =
428
+ isSelected && !isOptionContentAppliedToInput ? (
429
+ <CheckInstUIIcon inline={false} size={iconSize} color="inverseColor" />
430
+ ) : null
431
+
432
+ let optionProps: Partial<OptionsItemProps> = {
433
+ // passthrough props
434
+ ...omitProps(option.props, [
435
+ ...Option.allowedProps,
436
+ ...Options.Item.allowedProps
437
+ ]),
438
+ // props from selectable
439
+ ...getOptionProps({ id }),
440
+ // Options.Item props
441
+ renderBeforeLabel: getRenderOptionLabel(renderBeforeLabel),
442
+ renderAfterLabel: checkIcon ?? getRenderOptionLabel(renderAfterLabel)
443
+ }
444
+ // should option be treated as highlighted or selected
445
+ if (isSelected && isHighlighted) {
446
+ optionProps.variant = 'selected-highlighted'
447
+ optionProps.isSelected = true
448
+ } else if (isSelected) {
449
+ optionProps.variant = 'selected'
450
+ optionProps.isSelected = true
451
+ } else if (isHighlighted) {
452
+ optionProps.variant = 'highlighted'
453
+ }
454
+ // should option be treated as disabled
455
+ if (isDisabled) {
456
+ optionProps.variant = 'disabled'
457
+ optionProps = { ...optionProps, ...getDisabledOptionProps() }
458
+ } else {
459
+ // track as valid option if not disabled
460
+ this._optionIds.push(id)
461
+ }
462
+
463
+ return (
464
+ <MemoedOption optionsItemProps={optionProps} selectOption={option}>
465
+ {children}
466
+ </MemoedOption>
467
+ )
468
+ }
469
+
470
+ renderGroup(
471
+ group: GroupChild,
472
+ data: Pick<
473
+ SelectableRender,
474
+ 'getOptionProps' | 'getDisabledOptionProps'
475
+ > & {
476
+ isFirstChild: boolean
477
+ isLastChild: boolean
478
+ afterGroup: boolean
479
+ }
480
+ ) {
481
+ const {
482
+ getOptionProps,
483
+ getDisabledOptionProps,
484
+ isFirstChild,
485
+ isLastChild,
486
+ afterGroup
487
+ } = data
488
+ const { id, renderLabel, children, ...rest } = group.props
489
+ const groupChildren: (
490
+ | React.ReactElement<OptionsItemProps>
491
+ | React.ReactElement<OptionsSeparatorProps>
492
+ )[] = []
493
+ // add a separator above
494
+ if (!isFirstChild && !afterGroup) {
495
+ groupChildren.push(<Options.Separator />)
496
+ }
497
+ // create a sublist as a group
498
+ // a wrapping listitem will be created by Options
499
+ groupChildren.push(
500
+ <Options
501
+ id={id}
502
+ as="ul"
503
+ role="group"
504
+ renderLabel={renderLabel}
505
+ {...omitProps(rest, [...Options.allowedProps, ...Group.allowedProps])}
506
+ >
507
+ {Children.map(children as OptionChild[], (child) => {
508
+ return this.renderOption(child, {
509
+ getOptionProps,
510
+ getDisabledOptionProps
511
+ })
512
+ })}
513
+ </Options>
514
+ )
515
+ // add a separator below
516
+ if (!isLastChild) {
517
+ groupChildren.push(<Options.Separator />)
518
+ }
519
+
520
+ return groupChildren
521
+ }
522
+
523
+ renderList(
524
+ data: Pick<
525
+ SelectableRender,
526
+ 'getListProps' | 'getOptionProps' | 'getDisabledOptionProps'
527
+ >
528
+ ) {
529
+ const { getListProps, getOptionProps, getDisabledOptionProps } = data
530
+ const {
531
+ isShowingOptions,
532
+ optionsMaxWidth,
533
+ optionsMaxHeight,
534
+ visibleOptionsCount,
535
+ children
536
+ } = this.props
537
+
538
+ let lastWasGroup = false
539
+
540
+ const viewProps: Partial<ViewProps> = isShowingOptions
541
+ ? {
542
+ display: 'block',
543
+ overflowY: 'auto',
544
+ maxHeight:
545
+ optionsMaxHeight ||
546
+ this._optionHeight * visibleOptionsCount! -
547
+ // in Chrome, we need to prevent scrolling when the bottom area of last item is hovered
548
+ (utils.isChromium() ? this.SCROLL_TOLERANCE : 0),
549
+ maxWidth: optionsMaxWidth || this.width,
550
+ background: 'primary',
551
+ elementRef: (node: Element | null) => (this._listView = node),
552
+ borderRadius: 'inherit'
553
+ }
554
+ : { maxHeight: 0 }
555
+
556
+ return (
557
+ <View {...viewProps}>
558
+ <Options
559
+ {...getListProps({ as: 'ul', elementRef: this.handleListRef })}
560
+ >
561
+ {isShowingOptions
562
+ ? Children.map(children as SelectChildren, (child, index) => {
563
+ if (!child || !matchComponentTypes(child, [Group, Option])) {
564
+ return // ignore invalid children
565
+ }
566
+ if (matchComponentTypes<OptionChild>(child, [Option])) {
567
+ lastWasGroup = false
568
+ return this.renderOption(child, {
569
+ getOptionProps,
570
+ getDisabledOptionProps
571
+ })
572
+ }
573
+ if (matchComponentTypes<GroupChild>(child, [Group])) {
574
+ const afterGroup = lastWasGroup
575
+ lastWasGroup = true
576
+ return this.renderGroup(child, {
577
+ getOptionProps,
578
+ getDisabledOptionProps,
579
+ // for rendering separators appropriately
580
+ isFirstChild: index === 0,
581
+ isLastChild: index === Children.count(children) - 1,
582
+ afterGroup
583
+ })
584
+ }
585
+ return
586
+ })
587
+ : null}
588
+ </Options>
589
+ </View>
590
+ )
591
+ }
592
+
593
+ renderIcon() {
594
+ const { isShowingOptions, size = 'medium' } = this.props
595
+ const iconSize = selectSizeToIconSize[size]
596
+ return (
597
+ <span>
598
+ {isShowingOptions ? (
599
+ <ChevronUpInstUIIcon
600
+ inline={false}
601
+ size={iconSize}
602
+ color="baseColor"
603
+ />
604
+ ) : (
605
+ <ChevronDownInstUIIcon
606
+ inline={false}
607
+ size={iconSize}
608
+ color="baseColor"
609
+ />
610
+ )}
611
+ </span>
612
+ )
613
+ }
614
+
615
+ renderContentBeforeOrAfterInput(position: string) {
616
+ for (const child of this.childrenArray) {
617
+ if (matchComponentTypes<GroupChild>(child, [Group])) {
618
+ // Group found
619
+ const options = this.getGroupChildrenArray(child)
620
+ for (const option of options) {
621
+ if (option.props.isSelected) {
622
+ return position === 'before'
623
+ ? option.props.renderBeforeLabel
624
+ : option.props.renderAfterLabel
625
+ ? option.props.renderAfterLabel
626
+ : this.renderIcon()
627
+ }
628
+ }
629
+ } else {
630
+ // Ungrouped option found
631
+ if (child.props.isSelected) {
632
+ return position === 'before'
633
+ ? child.props.renderBeforeLabel
634
+ : child.props.renderAfterLabel
635
+ ? child.props.renderAfterLabel
636
+ : this.renderIcon()
637
+ }
638
+ }
639
+ }
640
+ // if no option with isSelected is found
641
+ if (position === 'after') {
642
+ return this.renderIcon()
643
+ }
644
+ return console.warn(
645
+ "isOptionContentAppliedToInput is set but no option has an isSelected='true' prop so desired content cannot be displayed in input filed"
646
+ )
647
+ }
648
+
649
+ handleInputContentRender(
650
+ renderLabelInput: Renderable,
651
+ inputValue: string | undefined,
652
+ isOptionContentAppliedToInput: boolean,
653
+ position: 'before' | 'after',
654
+ defaultReturn: Renderable
655
+ ): Renderable {
656
+ const isInputValueEmpty = !inputValue || inputValue === ''
657
+ if (renderLabelInput && isOptionContentAppliedToInput) {
658
+ if (!isInputValueEmpty) {
659
+ return this.renderContentBeforeOrAfterInput(position) as Renderable
660
+ }
661
+ return renderLabelInput
662
+ }
663
+ if (isOptionContentAppliedToInput) {
664
+ if (isInputValueEmpty) {
665
+ return defaultReturn
666
+ }
667
+ return this.renderContentBeforeOrAfterInput(position) as Renderable
668
+ }
669
+ if (renderLabelInput) {
670
+ return renderLabelInput
671
+ }
672
+ return defaultReturn
673
+ }
674
+
675
+ handleRenderBeforeInput() {
676
+ const { renderBeforeInput, inputValue, isOptionContentAppliedToInput } =
677
+ this.props
678
+ return this.handleInputContentRender(
679
+ renderBeforeInput,
680
+ inputValue,
681
+ isOptionContentAppliedToInput!,
682
+ 'before',
683
+ null // default for before
684
+ )
685
+ }
686
+
687
+ handleRenderAfterInput() {
688
+ const { renderAfterInput, inputValue, isOptionContentAppliedToInput } =
689
+ this.props
690
+ return this.handleInputContentRender(
691
+ renderAfterInput,
692
+ inputValue,
693
+ isOptionContentAppliedToInput!,
694
+ 'after',
695
+ this.renderIcon() // default for after
696
+ )
697
+ }
698
+
699
+ renderInput(
700
+ data: Pick<SelectableRender, 'getInputProps' | 'getTriggerProps'>
701
+ ) {
702
+ const { getInputProps, getTriggerProps } = data
703
+ const {
704
+ renderLabel,
705
+ inputValue,
706
+ placeholder,
707
+ isRequired,
708
+ shouldNotWrap,
709
+ size,
710
+ isInline,
711
+ width,
712
+ htmlSize,
713
+ messages,
714
+ renderBeforeInput,
715
+ renderAfterInput,
716
+ onFocus,
717
+ onBlur,
718
+ onInputChange,
719
+ onRequestHideOptions,
720
+ layout,
721
+ ...rest
722
+ } = this.props
723
+
724
+ const { interaction } = this
725
+ const passthroughProps = omitProps(rest, Select.allowedProps)
726
+ const { ref, ...triggerProps } = getTriggerProps({ ...passthroughProps })
727
+ const isEditable = typeof onInputChange !== 'undefined'
728
+
729
+ // props to ensure screen readers treat uneditable selects as accessible
730
+ // popup buttons rather than comboboxes.
731
+ const overrideProps: Partial<TextInputProps> = !isEditable
732
+ ? {
733
+ // We need role="combobox" for the 'open list' button shortcut to work
734
+ // with desktop screenreaders.
735
+ // But desktop Safari with Voiceover does not support proper combobox
736
+ // handling, a 'button' role is set as a workaround.
737
+ // See https://bugs.webkit.org/show_bug.cgi?id=236881
738
+ // Also on iOS Chrome with role='combobox' it announces unnecessarily
739
+ // that its 'read-only' and that this is a 'textfield', see INSTUI-4500
740
+ role:
741
+ utils.isSafari() ||
742
+ utils.isAndroidOrIOS() ||
743
+ (interaction === 'disabled' && utils.isChromium())
744
+ ? 'button'
745
+ : 'combobox',
746
+ title: inputValue,
747
+ 'aria-autocomplete': undefined,
748
+ 'aria-readonly': true
749
+ }
750
+ : interaction === 'disabled' && utils.isChromium()
751
+ ? { role: 'button' }
752
+ : {}
753
+
754
+ // backdoor to autocomplete attr to work around chrome autofill issues
755
+ if (passthroughProps['autoComplete']) {
756
+ overrideProps.autoComplete = passthroughProps['autoComplete']
757
+ }
758
+
759
+ const inputProps: Partial<TextInputProps> = {
760
+ id: this.id,
761
+ renderLabel,
762
+ placeholder,
763
+ size,
764
+ width,
765
+ htmlSize,
766
+ messages,
767
+ value: inputValue,
768
+ inputRef: utils.createChainedFunction(ref, this.handleInputRef),
769
+ inputContainerRef: this.handleInputContainerRef,
770
+ interaction:
771
+ interaction === 'enabled' && !isEditable
772
+ ? 'readonly' // prevent keyboard cursor
773
+ : interaction,
774
+ isRequired,
775
+ shouldNotWrap,
776
+ layout,
777
+ display: isInline ? 'inline-block' : 'block',
778
+ renderBeforeInput: this.handleRenderBeforeInput(),
779
+ // On iOS VoiceOver, if there is a custom element instead of the changing up and down arrow button
780
+ // the listbox closes on a swipe, so a DOM change is enforced by the key change
781
+ // that seems to inform VoiceOver to behave the correct way
782
+ renderAfterInput:
783
+ utils.isAndroidOrIOS() && renderAfterInput !== undefined ? (
784
+ <span key={this.props.isShowingOptions ? 'open' : 'closed'}>
785
+ {this.handleRenderAfterInput() as ReactNode}
786
+ </span>
787
+ ) : (
788
+ this.handleRenderAfterInput()
789
+ ),
790
+
791
+ // If `inputValue` is provided, we need to pass a default onChange handler,
792
+ // because TextInput `value` is a controlled prop,
793
+ // and onChange is not required for Select
794
+ // (before it was handled by TextInput's defaultProp)
795
+ onChange:
796
+ typeof onInputChange === 'function'
797
+ ? onInputChange
798
+ : inputValue
799
+ ? () => {}
800
+ : undefined,
801
+
802
+ onFocus,
803
+ onBlur: utils.createChainedFunction(onBlur, onRequestHideOptions),
804
+ ...overrideProps
805
+ }
806
+ // suppressHydrationWarning is needed because `role` depends on the browser type
807
+ return (
808
+ <TextInput
809
+ {...triggerProps}
810
+ {...getInputProps(inputProps)}
811
+ suppressHydrationWarning
812
+ {...(interaction === 'enabled' &&
813
+ !isEditable && {
814
+ themeOverride: (componentTheme) => ({
815
+ backgroundReadonlyColor: componentTheme.backgroundColor
816
+ })
817
+ })}
818
+ />
819
+ )
820
+ }
821
+
822
+ render() {
823
+ const {
824
+ constrain,
825
+ placement,
826
+ mountNode,
827
+ assistiveText,
828
+ isShowingOptions,
829
+ styles
830
+ } = this.props
831
+ // clear temporary option store
832
+ this._optionIds = []
833
+
834
+ const highlightedOptionId = this.highlightedOptionId
835
+ const selectedOptionId = this.selectedOptionId
836
+
837
+ return (
838
+ <Selectable
839
+ highlightedOptionId={highlightedOptionId}
840
+ isShowingOptions={isShowingOptions}
841
+ selectedOptionId={selectedOptionId}
842
+ {...this.getEventHandlers()}
843
+ >
844
+ {({
845
+ getRootProps,
846
+ getInputProps,
847
+ getTriggerProps,
848
+ getListProps,
849
+ getOptionProps,
850
+ getDisabledOptionProps,
851
+ getDescriptionProps
852
+ }) => (
853
+ <span
854
+ {...getRootProps()}
855
+ ref={(el) => {
856
+ this.ref = el
857
+ }}
858
+ data-cid={combineDataCid('Select', this.props)}
859
+ >
860
+ {this.renderInput({ getInputProps, getTriggerProps })}
861
+ <span {...getDescriptionProps()} css={styles?.assistiveText}>
862
+ {assistiveText}
863
+ </span>
864
+ <Popover
865
+ constrain={constrain}
866
+ placement={placement}
867
+ // On iOS VoiceOver, the Popover is mounted right after the input
868
+ // in order to be able to navigate through the list items with a swipe.
869
+ // The swipe would result in closing the listbox if mounted elsewhere.
870
+ mountNode={
871
+ mountNode !== undefined
872
+ ? mountNode
873
+ : utils.isAndroidOrIOS()
874
+ ? this.ref
875
+ : undefined
876
+ }
877
+ positionTarget={this._inputContainer}
878
+ isShowingContent={isShowingOptions}
879
+ shouldReturnFocus={false}
880
+ withArrow={false}
881
+ borderWidth={styles?.popoverBorderWidth as BorderWidth}
882
+ >
883
+ {this.renderList({
884
+ getListProps,
885
+ getOptionProps,
886
+ getDisabledOptionProps
887
+ })}
888
+ </Popover>
889
+ </span>
890
+ )}
891
+ </Selectable>
892
+ )
893
+ }
894
+ }
895
+
896
+ export default Select
897
+ export { Select }