@instructure/ui-drilldown 8.21.1-snapshot.10

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 (89) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE.md +27 -0
  3. package/README.md +30 -0
  4. package/es/Drilldown/DrilldownGroup/index.js +56 -0
  5. package/es/Drilldown/DrilldownGroup/props.js +42 -0
  6. package/es/Drilldown/DrilldownLocator.js +144 -0
  7. package/es/Drilldown/DrilldownOption/index.js +59 -0
  8. package/es/Drilldown/DrilldownOption/props.js +46 -0
  9. package/es/Drilldown/DrilldownPage/index.js +56 -0
  10. package/es/Drilldown/DrilldownPage/props.js +41 -0
  11. package/es/Drilldown/DrilldownSeparator/index.js +52 -0
  12. package/es/Drilldown/DrilldownSeparator/props.js +30 -0
  13. package/es/Drilldown/__examples__/Drilldown.examples.js +299 -0
  14. package/es/Drilldown/index.js +1241 -0
  15. package/es/Drilldown/locator.js +26 -0
  16. package/es/Drilldown/props.js +67 -0
  17. package/es/Drilldown/styles.js +82 -0
  18. package/es/Drilldown/theme.js +43 -0
  19. package/es/index.js +24 -0
  20. package/lib/Drilldown/DrilldownGroup/index.js +42 -0
  21. package/lib/Drilldown/DrilldownGroup/props.js +56 -0
  22. package/lib/Drilldown/DrilldownLocator.js +157 -0
  23. package/lib/Drilldown/DrilldownOption/index.js +45 -0
  24. package/lib/Drilldown/DrilldownOption/props.js +57 -0
  25. package/lib/Drilldown/DrilldownPage/index.js +66 -0
  26. package/lib/Drilldown/DrilldownPage/props.js +56 -0
  27. package/lib/Drilldown/DrilldownSeparator/index.js +38 -0
  28. package/lib/Drilldown/DrilldownSeparator/props.js +41 -0
  29. package/lib/Drilldown/__examples__/Drilldown.examples.js +289 -0
  30. package/lib/Drilldown/index.js +1253 -0
  31. package/lib/Drilldown/locator.js +40 -0
  32. package/lib/Drilldown/props.js +83 -0
  33. package/lib/Drilldown/styles.js +90 -0
  34. package/lib/Drilldown/theme.js +51 -0
  35. package/lib/index.js +13 -0
  36. package/lib/package.json +1 -0
  37. package/package.json +58 -0
  38. package/src/Drilldown/DrilldownGroup/index.tsx +64 -0
  39. package/src/Drilldown/DrilldownGroup/props.ts +149 -0
  40. package/src/Drilldown/DrilldownLocator.ts +101 -0
  41. package/src/Drilldown/DrilldownOption/index.tsx +68 -0
  42. package/src/Drilldown/DrilldownOption/props.ts +224 -0
  43. package/src/Drilldown/DrilldownPage/index.tsx +58 -0
  44. package/src/Drilldown/DrilldownPage/props.ts +129 -0
  45. package/src/Drilldown/DrilldownSeparator/index.tsx +60 -0
  46. package/src/Drilldown/DrilldownSeparator/props.ts +65 -0
  47. package/src/Drilldown/README.md +1515 -0
  48. package/src/Drilldown/__examples__/Drilldown.examples.tsx +318 -0
  49. package/src/Drilldown/index.tsx +1492 -0
  50. package/src/Drilldown/locator.ts +28 -0
  51. package/src/Drilldown/props.ts +378 -0
  52. package/src/Drilldown/styles.ts +95 -0
  53. package/src/Drilldown/theme.ts +47 -0
  54. package/src/index.ts +26 -0
  55. package/tsconfig.build.json +30 -0
  56. package/tsconfig.build.tsbuildinfo +1 -0
  57. package/tsconfig.json +4 -0
  58. package/types/Drilldown/DrilldownGroup/index.d.ts +59 -0
  59. package/types/Drilldown/DrilldownGroup/index.d.ts.map +1 -0
  60. package/types/Drilldown/DrilldownGroup/props.d.ts +65 -0
  61. package/types/Drilldown/DrilldownGroup/props.d.ts.map +1 -0
  62. package/types/Drilldown/DrilldownLocator.d.ts +652 -0
  63. package/types/Drilldown/DrilldownLocator.d.ts.map +1 -0
  64. package/types/Drilldown/DrilldownOption/index.d.ts +134 -0
  65. package/types/Drilldown/DrilldownOption/index.d.ts.map +1 -0
  66. package/types/Drilldown/DrilldownOption/props.d.ts +116 -0
  67. package/types/Drilldown/DrilldownOption/props.d.ts.map +1 -0
  68. package/types/Drilldown/DrilldownPage/index.d.ts +45 -0
  69. package/types/Drilldown/DrilldownPage/index.d.ts.map +1 -0
  70. package/types/Drilldown/DrilldownPage/props.d.ts +51 -0
  71. package/types/Drilldown/DrilldownPage/props.d.ts.map +1 -0
  72. package/types/Drilldown/DrilldownSeparator/index.d.ts +27 -0
  73. package/types/Drilldown/DrilldownSeparator/index.d.ts.map +1 -0
  74. package/types/Drilldown/DrilldownSeparator/props.d.ts +18 -0
  75. package/types/Drilldown/DrilldownSeparator/props.d.ts.map +1 -0
  76. package/types/Drilldown/__examples__/Drilldown.examples.d.ts +5 -0
  77. package/types/Drilldown/__examples__/Drilldown.examples.d.ts.map +1 -0
  78. package/types/Drilldown/index.d.ts +246 -0
  79. package/types/Drilldown/index.d.ts.map +1 -0
  80. package/types/Drilldown/locator.d.ts +4 -0
  81. package/types/Drilldown/locator.d.ts.map +1 -0
  82. package/types/Drilldown/props.d.ts +189 -0
  83. package/types/Drilldown/props.d.ts.map +1 -0
  84. package/types/Drilldown/styles.d.ts +15 -0
  85. package/types/Drilldown/styles.d.ts.map +1 -0
  86. package/types/Drilldown/theme.d.ts +10 -0
  87. package/types/Drilldown/theme.d.ts.map +1 -0
  88. package/types/index.d.ts +3 -0
  89. package/types/index.d.ts.map +1 -0
@@ -0,0 +1,1492 @@
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
+ /** @jsx jsx */
26
+ /** @jsxFrag React.Fragment */
27
+ import React, { Component, ReactElement } from 'react'
28
+
29
+ import { warn, error } from '@instructure/console'
30
+ import { testable } from '@instructure/ui-testable'
31
+ import { contains, containsActiveElement } from '@instructure/ui-dom-utils'
32
+ import {
33
+ callRenderProp,
34
+ matchComponentTypes,
35
+ omitProps,
36
+ safeCloneElement,
37
+ withDeterministicId
38
+ } from '@instructure/ui-react-utils'
39
+
40
+ import { View } from '@instructure/ui-view'
41
+ import { Options } from '@instructure/ui-options'
42
+ import type {
43
+ OptionsProps,
44
+ OptionsItemProps,
45
+ OptionsSeparatorProps
46
+ } from '@instructure/ui-options'
47
+ import { Popover } from '@instructure/ui-popover'
48
+ import { Selectable } from '@instructure/ui-selectable'
49
+ import type { SelectableRender } from '@instructure/ui-selectable'
50
+ import {
51
+ IconArrowOpenStartSolid,
52
+ IconArrowOpenEndSolid,
53
+ IconCheckSolid
54
+ } from '@instructure/ui-icons'
55
+
56
+ import { withStyle, jsx } from '@instructure/emotion'
57
+
58
+ import { DrilldownSeparator } from './DrilldownSeparator'
59
+ import { DrilldownOption } from './DrilldownOption'
60
+ import type { DrilldownOptionValue } from './DrilldownOption/props'
61
+ import { DrilldownGroup } from './DrilldownGroup'
62
+ import type { DrilldownGroupProps, GroupChildren } from './DrilldownGroup/props'
63
+ import { DrilldownPage } from './DrilldownPage'
64
+ import type { DrilldownPageProps, PageChildren } from './DrilldownPage/props'
65
+
66
+ import generateStyle from './styles'
67
+ import generateComponentTheme from './theme'
68
+
69
+ import { propTypes, allowedProps } from './props'
70
+
71
+ import type {
72
+ DrilldownProps,
73
+ DrilldownStyleProps,
74
+ DrilldownState,
75
+ PageChild,
76
+ OptionChild,
77
+ GroupChild,
78
+ SeparatorChild
79
+ } from './props'
80
+
81
+ // Additional data we need to track on the Options,
82
+ // but shouldn't be settable via props
83
+ type OptionData = {
84
+ groupProps?: DrilldownGroupProps
85
+ }
86
+
87
+ // Contains the Option ComponentElement and the extra data we need track on it,
88
+ // but don't want to expose as props
89
+ type MappedOption = OptionChild & OptionData
90
+
91
+ // Contains the props object of the Page
92
+ // with the `children` transformed into an array
93
+ type MappedPage = DrilldownPageProps & {
94
+ children: PageChildren[]
95
+ }
96
+
97
+ // An object with the mapped Pages with their id as keys
98
+ type PageMap = Record<string, MappedPage>
99
+
100
+ // A Map width the selected options in a group, with the id as key and their value
101
+ type SelectedGroupOptionsMap = Map<string, DrilldownOptionValue>
102
+
103
+ /**
104
+ ---
105
+ category: components
106
+ experimental: true
107
+ ---
108
+ @tsProps
109
+ **/
110
+ @withDeterministicId()
111
+ @withStyle(generateStyle, generateComponentTheme)
112
+ @testable()
113
+ class Drilldown extends Component<DrilldownProps, DrilldownState> {
114
+ static readonly componentId = 'Drilldown'
115
+
116
+ static propTypes = propTypes
117
+ static allowedProps = allowedProps
118
+ static defaultProps = {
119
+ disabled: false,
120
+ rotateFocus: true,
121
+ as: 'ul',
122
+ role: 'menu',
123
+ overflowX: 'auto',
124
+ overflowY: 'auto',
125
+ placement: 'bottom center',
126
+ defaultShow: false,
127
+ shouldHideOnSelect: true,
128
+ shouldContainFocus: false,
129
+ shouldReturnFocus: true,
130
+ withArrow: true,
131
+ offsetX: 0,
132
+ offsetY: 0
133
+ }
134
+
135
+ static Group = DrilldownGroup
136
+ static Option = DrilldownOption
137
+ static Page = DrilldownPage
138
+ static Separator = DrilldownSeparator
139
+
140
+ private _drilldownRef: HTMLDivElement | null = null
141
+ private _popover: Popover | null = null
142
+ private _trigger: (React.ReactInstance & { focus?: () => void }) | null = null
143
+ private _containerElement: HTMLDivElement | null = null
144
+
145
+ readonly _id: string
146
+ readonly _triggerId: string
147
+ readonly _headerBackId: string
148
+ readonly _headerTitleId: string
149
+ readonly _headerTitleLabelId: string
150
+ readonly _headerActionId: string
151
+
152
+ // Array of visited page ids in "breadcrumbs" fashion
153
+ private readonly _pageHistory: string[]
154
+
155
+ // Map of the active options on the page (includes header options too)
156
+ _activeOptionsMap: {
157
+ [optionId: string]: MappedOption
158
+ } = {}
159
+
160
+ // Map of the selected options in groups (grouped by group id)
161
+ _selectedGroupOptionsMap: {
162
+ [groupId: string]: SelectedGroupOptionsMap
163
+ } = {}
164
+
165
+ ref: HTMLDivElement | Element | null = null
166
+
167
+ handleRef = (el: HTMLDivElement | Element | null) => {
168
+ // ref and elementRef have to be set together for the same element
169
+ const { elementRef } = this.props
170
+
171
+ this.ref = el as HTMLDivElement
172
+
173
+ if (typeof elementRef === 'function') {
174
+ elementRef(el as HTMLDivElement)
175
+ }
176
+ }
177
+
178
+ handleDrilldownRef = (el: Element | null) => {
179
+ const { drilldownRef } = this.props
180
+
181
+ this._drilldownRef = el as HTMLDivElement
182
+
183
+ if (typeof drilldownRef === 'function') {
184
+ drilldownRef(el as HTMLDivElement)
185
+ }
186
+
187
+ // setting ref for "non-popover" version, the drilldown itself
188
+ // (if a trigger is provided, the Popover sets the ref)
189
+ if (!this.props.trigger) {
190
+ this.handleRef(el as HTMLDivElement)
191
+ }
192
+ }
193
+
194
+ constructor(props: DrilldownProps) {
195
+ super(props)
196
+
197
+ this.state = {
198
+ isShowingPopover: props.trigger ? !!props.show : false,
199
+ activePageId: props.rootPageId,
200
+ highlightedOptionId: undefined,
201
+ lastSelectedId: undefined
202
+ }
203
+
204
+ this._pageHistory = [props.rootPageId]
205
+
206
+ this.setDefaultSelected()
207
+
208
+ this._id = props.id || props.deterministicId!()
209
+ this._triggerId = props.deterministicId!('Drilldown-Trigger')
210
+ this._headerBackId = props.deterministicId!('DrilldownHeader-Back')
211
+ this._headerTitleId = props.deterministicId!('DrilldownHeader-Title')
212
+ this._headerTitleLabelId = props.deterministicId!(
213
+ 'DrilldownHeader-Title-Label'
214
+ )
215
+ this._headerActionId = props.deterministicId!('DrilldownHeader-Action')
216
+ }
217
+
218
+ componentDidMount() {
219
+ this.props.makeStyles?.(this.makeStylesVariables)
220
+ }
221
+
222
+ componentDidUpdate(_prevProps: DrilldownProps, prevState: DrilldownState) {
223
+ this.props.makeStyles?.(this.makeStylesVariables)
224
+
225
+ if (prevState.activePageId !== this.state.activePageId) {
226
+ // on page change with mouse navigation, some option can get rendered
227
+ // under the cursor and get focused, so we need to wait a tick to see
228
+ // if anything gets focused, otherwise we focus the whole drilldown
229
+ setTimeout(() => {
230
+ if (!this.focused()) {
231
+ this.focus()
232
+ }
233
+ }, 0)
234
+ }
235
+
236
+ // if the current page was removed
237
+ if (!this.currentPage) {
238
+ if (this.previousPage) {
239
+ this.goToPreviousPage()
240
+ } else {
241
+ this.goToPage(this.props.rootPageId)
242
+ }
243
+ }
244
+
245
+ if (
246
+ this.state.highlightedOptionId &&
247
+ !this.getPageChildById(this.state.highlightedOptionId)
248
+ ) {
249
+ this.setState({
250
+ highlightedOptionId: undefined
251
+ })
252
+ }
253
+ }
254
+
255
+ get makeStylesVariables(): DrilldownStyleProps {
256
+ return {
257
+ hasHighlightedOption: !!this.state.highlightedOptionId
258
+ }
259
+ }
260
+
261
+ get activeOptionIds() {
262
+ return Object.keys(this._activeOptionsMap)
263
+ }
264
+
265
+ get activeOptions() {
266
+ return Object.values(this._activeOptionsMap)
267
+ }
268
+
269
+ get pages(): PageChild[] {
270
+ const { children } = this.props
271
+ return React.Children.toArray(children || []) as PageChild[]
272
+ }
273
+
274
+ get pageMap(): PageMap | undefined {
275
+ const { children } = this.props
276
+
277
+ if (!children) {
278
+ return undefined
279
+ }
280
+
281
+ const map: PageMap | undefined = {}
282
+
283
+ this.pages.forEach((page) => {
284
+ const { props } = page
285
+ const { children } = props
286
+
287
+ map[props.id] = {
288
+ ...props,
289
+ children: React.Children.toArray(children || []) as PageChildren[]
290
+ }
291
+ })
292
+
293
+ return map
294
+ }
295
+
296
+ get isOnRootPage() {
297
+ return this.state.activePageId === this.props.rootPageId
298
+ }
299
+
300
+ get currentPage(): MappedPage | undefined {
301
+ return this.getPageById(this.state.activePageId)
302
+ }
303
+
304
+ get previousPage(): MappedPage | undefined {
305
+ const previousPageId = this._pageHistory[this._pageHistory.length - 2]
306
+ return this.getPageById(previousPageId)
307
+ }
308
+
309
+ // Returns the navigation methods and the page history.
310
+ // These are used in some callbacks to expose the navigation logic.
311
+ get exposedNavigationProps() {
312
+ const { goToPage, goToPreviousPage } = this
313
+
314
+ // we make a copy of the array so the original history
315
+ // cannot be modified from the outside
316
+ const pageHistory = [...this._pageHistory]
317
+
318
+ return {
319
+ pageHistory,
320
+ goToPage,
321
+ goToPreviousPage
322
+ }
323
+ }
324
+
325
+ get currentPageAriaLabel() {
326
+ // return, if explicitly set
327
+ if (this.props['aria-labelledby']) {
328
+ return this.props['aria-labelledby']
329
+ }
330
+
331
+ // if it has title, point to the title label content
332
+ if (this.currentPage?.renderTitle) {
333
+ return this._headerTitleLabelId
334
+ }
335
+
336
+ // if root page has no title, try the trigger, if exists
337
+ if (this.isOnRootPage && this.props.trigger) {
338
+ return this._triggerId
339
+ }
340
+
341
+ return undefined
342
+ }
343
+
344
+ getChildrenArray<C extends PageChildren | GroupChildren = PageChildren>(
345
+ children?: C | C[]
346
+ ) {
347
+ if (!children) {
348
+ return []
349
+ }
350
+ return Array.isArray(children) ? children : ([children] as C[])
351
+ }
352
+
353
+ getPageById(id?: string): MappedPage | undefined {
354
+ return this.pageMap && id ? this.pageMap[id] : undefined
355
+ }
356
+
357
+ getPageChildById(id?: string) {
358
+ return id ? this._activeOptionsMap[id] : undefined
359
+ }
360
+
361
+ setDefaultSelected() {
362
+ this.pages.forEach((page) => {
363
+ const { children } = page.props
364
+
365
+ this.getChildrenArray(children).forEach((child) => {
366
+ if (matchComponentTypes<GroupChild>(child, [DrilldownGroup])) {
367
+ const {
368
+ id: groupId,
369
+ selectableType,
370
+ defaultSelected: groupDefaultSelected = [],
371
+ children: groupChildren
372
+ } = child.props
373
+
374
+ if (!selectableType) {
375
+ return
376
+ }
377
+
378
+ if (selectableType === 'single' && groupDefaultSelected.length > 1) {
379
+ error(
380
+ false,
381
+ `Radio type selectable groups can have only one item selected! Group with id "${groupId}" cannot select multiple items: [${groupDefaultSelected.join(
382
+ ', '
383
+ )}]!`
384
+ )
385
+
386
+ return
387
+ }
388
+
389
+ this._selectedGroupOptionsMap[groupId] = new Map()
390
+
391
+ this.getChildrenArray(groupChildren)?.forEach((groupChild) => {
392
+ if (
393
+ matchComponentTypes<OptionChild>(groupChild, [DrilldownOption])
394
+ ) {
395
+ const {
396
+ id: optionId,
397
+ value: optionValue,
398
+ defaultSelected: optionDefaultSelected
399
+ } = groupChild.props
400
+
401
+ // if explicitly set to false, it should override the group's
402
+ if (optionDefaultSelected === false) {
403
+ return
404
+ }
405
+
406
+ const isGroupDefaultSelected =
407
+ typeof optionValue !== 'undefined' &&
408
+ groupDefaultSelected
409
+ .filter((value) => typeof value !== 'undefined')
410
+ .includes(optionValue)
411
+
412
+ if (optionDefaultSelected || isGroupDefaultSelected) {
413
+ this._selectedGroupOptionsMap[groupId].set(
414
+ optionId,
415
+ optionValue
416
+ )
417
+ }
418
+ }
419
+ })
420
+ }
421
+ })
422
+ })
423
+ }
424
+
425
+ get selectedGroupOptionIdsArray() {
426
+ return Object.values(this._selectedGroupOptionsMap)
427
+ .map((groupIdMap) => Array.from(groupIdMap.keys()))
428
+ .flat()
429
+ }
430
+
431
+ get headerChildren() {
432
+ const { currentPage } = this
433
+ const { styles, deterministicId } = this.props
434
+
435
+ const headerChildren: PageChildren[] = []
436
+
437
+ if (!currentPage) {
438
+ return headerChildren
439
+ }
440
+
441
+ const {
442
+ children,
443
+ renderBackButtonLabel,
444
+ renderTitle,
445
+ renderActionLabel,
446
+ onHeaderActionClicked,
447
+ withoutHeaderSeparator
448
+ } = currentPage
449
+
450
+ // Back navigation option
451
+ if (this.previousPage) {
452
+ const prevPageTitle = callRenderProp(this.previousPage.renderTitle)
453
+
454
+ const backButtonLabel: React.ReactNode = callRenderProp(
455
+ renderBackButtonLabel,
456
+ prevPageTitle
457
+ )
458
+
459
+ headerChildren.push(
460
+ <DrilldownOption
461
+ id={this._headerBackId}
462
+ onOptionClick={this.handleBackButtonClick}
463
+ >
464
+ <div css={styles?.headerBack} role="presentation">
465
+ {backButtonLabel}
466
+ </div>
467
+ </DrilldownOption>
468
+ )
469
+ }
470
+
471
+ // Header title
472
+ if (renderTitle) {
473
+ const title = callRenderProp(renderTitle)
474
+
475
+ if (title) {
476
+ headerChildren.push(
477
+ <DrilldownOption
478
+ id={this._headerTitleId}
479
+ role="presentation"
480
+ aria-hidden="true"
481
+ >
482
+ <div css={styles?.headerTitle} id={this._headerTitleLabelId}>
483
+ {title}
484
+ </div>
485
+ </DrilldownOption>
486
+ )
487
+ }
488
+ }
489
+
490
+ // Action label
491
+ if (renderActionLabel) {
492
+ const actionLabel = callRenderProp(renderActionLabel)
493
+
494
+ if (actionLabel) {
495
+ headerChildren.push(
496
+ <DrilldownOption
497
+ id={this._headerActionId}
498
+ themeOverride={{ color: styles?.headerActionColor }}
499
+ onOptionClick={(event) => {
500
+ if (typeof onHeaderActionClicked === 'function') {
501
+ onHeaderActionClicked(event)
502
+ }
503
+ }}
504
+ >
505
+ {actionLabel}
506
+ </DrilldownOption>
507
+ )
508
+ }
509
+ }
510
+
511
+ // header separator
512
+ if (
513
+ headerChildren.length > 0 &&
514
+ children.length > 0 &&
515
+ !withoutHeaderSeparator
516
+ ) {
517
+ headerChildren.push(
518
+ <DrilldownSeparator
519
+ id={deterministicId!('DrilldownHeader-Separator')}
520
+ />
521
+ )
522
+ }
523
+
524
+ return headerChildren
525
+ }
526
+
527
+ get shown() {
528
+ return this.props.trigger ? this.state.isShowingPopover : true
529
+ }
530
+
531
+ show = (event: React.SyntheticEvent) => {
532
+ if (this._popover) {
533
+ this._popover.show(event as React.UIEvent | React.FocusEvent)
534
+ this.setState({ isShowingPopover: true })
535
+ }
536
+ }
537
+
538
+ hide = (event: React.SyntheticEvent) => {
539
+ if (this._popover) {
540
+ this._popover.hide(event as React.UIEvent | React.FocusEvent)
541
+ this.setState({ isShowingPopover: false })
542
+ this.reset()
543
+ }
544
+ }
545
+
546
+ reset() {
547
+ this._activeOptionsMap = {}
548
+ this.setState({ highlightedOptionId: undefined })
549
+ }
550
+
551
+ public focus() {
552
+ if (this.shown) {
553
+ error(
554
+ !!this._drilldownRef?.focus,
555
+ '[Drilldown] Could not focus the drilldown.'
556
+ )
557
+ this._drilldownRef?.focus()
558
+ } else {
559
+ error(!!this._trigger?.focus, '[Drilldown] Could not focus the trigger.')
560
+ this._trigger!.focus!()
561
+ }
562
+ }
563
+
564
+ public focused() {
565
+ if (this.shown) {
566
+ return containsActiveElement(this._drilldownRef)
567
+ } else {
568
+ return containsActiveElement(this._trigger)
569
+ }
570
+ }
571
+
572
+ focusOption(id: string) {
573
+ const container = this._containerElement
574
+
575
+ const optionElement = container?.querySelector(
576
+ `[id="${id}"]`
577
+ ) as HTMLSpanElement
578
+
579
+ optionElement?.focus()
580
+ }
581
+
582
+ handleOptionHighlight = (
583
+ _event: React.SyntheticEvent,
584
+ { id, direction }: { id?: string; direction?: -1 | 1 }
585
+ ) => {
586
+ const { rotateFocus } = this.props
587
+ const { highlightedOptionId } = this.state
588
+
589
+ // if id exists, use that, or calculate from direction
590
+ let highlightId = this.getPageChildById(id) ? id : undefined
591
+
592
+ if (!highlightId) {
593
+ if (!highlightedOptionId) {
594
+ // nothing highlighted yet, highlight first option
595
+ highlightId = this.activeOptionIds[0]
596
+ } else if (direction) {
597
+ // if it has direction, find next id based on it
598
+ const index = this.activeOptionIds.indexOf(highlightedOptionId)
599
+ const newIndex = index + direction
600
+
601
+ highlightId = index > -1 ? this.activeOptionIds[newIndex] : undefined
602
+
603
+ if (rotateFocus) {
604
+ const lastOptionsIndex = this.activeOptionIds.length - 1
605
+
606
+ if (newIndex < 0) {
607
+ highlightId = this.activeOptionIds[lastOptionsIndex]
608
+ }
609
+ if (newIndex > lastOptionsIndex) {
610
+ highlightId = this.activeOptionIds[0]
611
+ }
612
+ }
613
+ }
614
+ }
615
+
616
+ if (highlightId) {
617
+ // only highlight if id exists as a valid option
618
+ this.setState({ highlightedOptionId: highlightId }, () => {
619
+ this.focusOption(highlightId!)
620
+ })
621
+ }
622
+ }
623
+
624
+ // Navigates to the page and also returns the new and old pageIds
625
+ public goToPage = (newPageId: string) => {
626
+ if (!newPageId) {
627
+ warn(false, `Cannot go to page because there was no page id provided.`)
628
+ return undefined
629
+ }
630
+
631
+ // TS complains that it cannot be true, but since it is an exposed method,
632
+ // it is better if we provide a warning for this case too
633
+ if (typeof newPageId !== 'string') {
634
+ warn(
635
+ false,
636
+ `Cannot go to page because parameter newPageId has to be string (valid page id). Current newPageId is "${typeof newPageId}".`
637
+ )
638
+ return undefined
639
+ }
640
+
641
+ if (!this.pageMap?.[newPageId]) {
642
+ warn(
643
+ false,
644
+ `Cannot go to page because page with id: "${newPageId}" doesn't exist.`
645
+ )
646
+ return undefined
647
+ }
648
+
649
+ // the last page id in the history is the current one,
650
+ // it will become the "prevPage"
651
+ const prevPageId = this._pageHistory[this._pageHistory.length - 1]
652
+ const idxInHistory = this._pageHistory.indexOf(newPageId)
653
+
654
+ if (idxInHistory < 0) {
655
+ // if it is not in the page history, we have to add it
656
+ this._pageHistory.push(newPageId)
657
+ } else {
658
+ // if it was already in the history, we go back to that page,
659
+ // and clear the rest from the history
660
+ this._pageHistory.splice(idxInHistory + 1, this._pageHistory.length - 1)
661
+ }
662
+
663
+ this.setState({
664
+ activePageId: newPageId,
665
+ highlightedOptionId: undefined
666
+ })
667
+
668
+ return { prevPageId, newPageId }
669
+ }
670
+
671
+ public goToPreviousPage = () => {
672
+ if (!this.previousPage) {
673
+ warn(
674
+ false,
675
+ `There is no previous page to go to. The current page history is: [${this._pageHistory.join(
676
+ ', '
677
+ )}].`
678
+ )
679
+ return undefined
680
+ }
681
+
682
+ // If there is a previous page, goToPage will succeed and return the data
683
+ const { newPageId, prevPageId } = this.goToPage(this.previousPage.id)!
684
+ return { newPageId, prevPageId }
685
+ }
686
+
687
+ handleBackButtonClick = () => {
688
+ const { onBackButtonClicked } = this.currentPage!
689
+
690
+ // we only display the back button when there is a page to go back to
691
+ const { newPageId, prevPageId } = this.goToPreviousPage()!
692
+
693
+ if (typeof onBackButtonClicked === 'function') {
694
+ onBackButtonClicked(newPageId, prevPageId)
695
+ }
696
+ }
697
+
698
+ handleGroupOptionSelected(
699
+ event: React.SyntheticEvent,
700
+ selectedOption: MappedOption
701
+ ) {
702
+ const { id: optionId, value } = selectedOption.props
703
+ const {
704
+ id: groupId,
705
+ selectableType,
706
+ onSelect: groupOnSelect
707
+ } = selectedOption.groupProps!
708
+
709
+ const { onSelect } = this.props
710
+
711
+ if (this._selectedGroupOptionsMap[groupId].has(optionId)) {
712
+ // toggle off, if already selected
713
+ this._selectedGroupOptionsMap[groupId].delete(optionId)
714
+ } else {
715
+ if (selectableType === 'multiple') {
716
+ // "checkbox"
717
+ this._selectedGroupOptionsMap[groupId].set(optionId, value)
718
+ } else if (selectableType === 'single') {
719
+ // "radio"
720
+ this._selectedGroupOptionsMap[groupId] = new Map()
721
+ this._selectedGroupOptionsMap[groupId].set(optionId, value)
722
+ }
723
+ }
724
+
725
+ // needed for rerender
726
+ this.setState({ lastSelectedId: optionId })
727
+
728
+ const { groupProps, ...option } = selectedOption
729
+ const selectedOptionValuesInGroup = Array.from(
730
+ this._selectedGroupOptionsMap[groupId].values()
731
+ )
732
+
733
+ if (typeof groupOnSelect === 'function') {
734
+ groupOnSelect(event, {
735
+ value: selectedOptionValuesInGroup,
736
+ isSelected: this._selectedGroupOptionsMap[groupId].has(optionId),
737
+ selectedOption: option,
738
+ drilldown: this
739
+ })
740
+ }
741
+
742
+ if (typeof onSelect === 'function') {
743
+ onSelect(event, {
744
+ value: selectedOptionValuesInGroup,
745
+ isSelected: this._selectedGroupOptionsMap[groupId].has(optionId),
746
+ selectedOption: option,
747
+ drilldown: this
748
+ })
749
+ }
750
+ }
751
+
752
+ handleOptionSelect = (
753
+ event: React.SyntheticEvent,
754
+ { id }: { id?: string }
755
+ ) => {
756
+ const selectedOption = this.getPageChildById(id)
757
+
758
+ if (
759
+ !id ||
760
+ !selectedOption ||
761
+ selectedOption.props.disabled ||
762
+ (event.target as HTMLElement).getAttribute('disabled') ||
763
+ (event.target as HTMLElement).getAttribute('aria-disabled')
764
+ ) {
765
+ event.preventDefault()
766
+ event.stopPropagation()
767
+ return
768
+ }
769
+
770
+ const { shouldHideOnSelect, onSelect } = this.props
771
+
772
+ const { groupProps, ...selectedOptionChild } = selectedOption
773
+ const { subPageId, href, value, onOptionClick } = selectedOptionChild.props
774
+
775
+ if (typeof onOptionClick === 'function') {
776
+ onOptionClick(event, {
777
+ optionId: id,
778
+ drilldown: this,
779
+ ...this.exposedNavigationProps
780
+ })
781
+ }
782
+
783
+ if (subPageId) {
784
+ this.goToPage(subPageId)
785
+ }
786
+
787
+ if (href) {
788
+ const optionEl = this._drilldownRef?.querySelector(
789
+ `#${id}`
790
+ ) as HTMLLinkElement
791
+ const isLink = optionEl.tagName.toLowerCase() === 'a'
792
+
793
+ // we need this check because in some cases
794
+ // we ignore href prop in renderOption()
795
+ if (isLink && optionEl?.href) {
796
+ optionEl.click()
797
+ }
798
+ }
799
+
800
+ if (groupProps?.selectableType) {
801
+ this.handleGroupOptionSelected(event, selectedOption)
802
+ } else {
803
+ if (typeof onSelect === 'function') {
804
+ onSelect(event, {
805
+ value,
806
+ isSelected: true,
807
+ selectedOption: selectedOptionChild,
808
+ drilldown: this
809
+ })
810
+ }
811
+ }
812
+
813
+ // should prevent closing on page navigation
814
+ if (shouldHideOnSelect && !subPageId && id !== this._headerBackId) {
815
+ this.hide(event as React.UIEvent)
816
+ }
817
+ }
818
+
819
+ // Setting extra logic for keyboard navigation.
820
+ // Enter, Esc and up/down/home/end keys are handled by Selectable.
821
+ handleKeyDown = (event: React.KeyboardEvent) => {
822
+ const id = (event.target as HTMLElement).id
823
+ const option = this.getPageChildById(id)
824
+
825
+ // On Space...
826
+ if ([' ', 'space', 'Space'].includes(event.key)) {
827
+ // we need to preventDefault so the page doesn't scroll on Space
828
+ event.preventDefault()
829
+ event.stopPropagation()
830
+
831
+ // for options, Space it has to work as Enter (get selected)
832
+ if (option) {
833
+ this.handleOptionSelect(event, { id })
834
+ }
835
+ }
836
+
837
+ // On Right arrow...
838
+ if (event.key === 'ArrowRight') {
839
+ // if the option has subpage, we open it
840
+ if (option?.props.subPageId) {
841
+ this.handleOptionSelect(event, { id })
842
+ }
843
+ }
844
+
845
+ // On Left arrow...
846
+ if (event.key === 'ArrowLeft') {
847
+ // if it is possible, we go a level up in the history
848
+ if (this._pageHistory.length > 1) {
849
+ this.goToPreviousPage()
850
+ }
851
+
852
+ // if on root page and popover is open, we close it
853
+ if (this.isOnRootPage && this._popover?.shown) {
854
+ this._popover.hide(event)
855
+ }
856
+ }
857
+ }
858
+
859
+ handleToggle = (event: React.UIEvent | React.FocusEvent, shown: boolean) => {
860
+ const { onToggle } = this.props
861
+
862
+ this.setState({ isShowingPopover: shown })
863
+
864
+ if (typeof onToggle === 'function') {
865
+ onToggle(event, {
866
+ shown,
867
+ drilldown: this,
868
+ ...this.exposedNavigationProps
869
+ })
870
+ }
871
+ }
872
+
873
+ renderList(
874
+ getOptionProps: SelectableRender['getOptionProps'],
875
+ getDisabledOptionProps: SelectableRender['getDisabledOptionProps']
876
+ ) {
877
+ const { currentPage, headerChildren } = this
878
+
879
+ if (!currentPage) {
880
+ return null
881
+ }
882
+
883
+ const pageChildren: PageChildren[] = [
884
+ ...headerChildren,
885
+ ...currentPage.children
886
+ ]
887
+
888
+ // for tracking if the last item was a Separator or not
889
+ let lastItemWasSeparator = false
890
+
891
+ return pageChildren.map((child, index) => {
892
+ /**
893
+ * --- RENDER GROUP ---
894
+ */
895
+ if (matchComponentTypes<GroupChild>(child, [DrilldownGroup])) {
896
+ const isFirstChild = index === 0
897
+ const isLastChild = index === pageChildren.length - 1
898
+ const afterSeparator = lastItemWasSeparator
899
+
900
+ const needsFirstSeparator =
901
+ !child.props.withoutSeparators && !isFirstChild && !afterSeparator
902
+ const needsLastSeparator =
903
+ !child.props.withoutSeparators && !isLastChild
904
+
905
+ lastItemWasSeparator = needsLastSeparator
906
+
907
+ return this.renderGroup(
908
+ child,
909
+ getOptionProps,
910
+ getDisabledOptionProps,
911
+ // for rendering separators appropriately
912
+ needsFirstSeparator,
913
+ needsLastSeparator
914
+ )
915
+
916
+ /**
917
+ * --- RENDER SEPARATOR ---
918
+ */
919
+ } else if (
920
+ matchComponentTypes<SeparatorChild>(child, [DrilldownSeparator])
921
+ ) {
922
+ // if the last item was separator, we don't want to duplicate it
923
+ if (lastItemWasSeparator) {
924
+ return null
925
+ }
926
+ lastItemWasSeparator = true
927
+ return this.renderSeparator(child)
928
+
929
+ /**
930
+ * --- RENDER OPTION ---
931
+ */
932
+ } else if (matchComponentTypes<OptionChild>(child, [DrilldownOption])) {
933
+ lastItemWasSeparator = false
934
+ return this.renderOption(child, getOptionProps, getDisabledOptionProps)
935
+ } else {
936
+ return null
937
+ }
938
+ })
939
+ }
940
+
941
+ renderSeparator(separator: SeparatorChild) {
942
+ const { id, themeOverride, ...props } = separator.props
943
+ return (
944
+ <Options.Separator
945
+ {...props}
946
+ id={id}
947
+ key={id}
948
+ role="separator"
949
+ // we pass the themeOverride to Options.Separator
950
+ themeOverride={themeOverride}
951
+ />
952
+ )
953
+ }
954
+
955
+ renderOption(
956
+ option: OptionChild,
957
+ getOptionProps: SelectableRender['getOptionProps'],
958
+ getDisabledOptionProps: SelectableRender['getDisabledOptionProps'],
959
+ groupProps?: DrilldownGroupProps
960
+ ) {
961
+ const { styles } = this.props
962
+ let isSelected = false
963
+
964
+ const {
965
+ id,
966
+ children,
967
+ href,
968
+ as,
969
+ role,
970
+ subPageId,
971
+ disabled,
972
+ renderLabelInfo,
973
+ renderBeforeLabel,
974
+ renderAfterLabel,
975
+ beforeLabelContentVAlign,
976
+ afterLabelContentVAlign,
977
+ description,
978
+ descriptionRole,
979
+ elementRef,
980
+ themeOverride
981
+ } = option.props
982
+
983
+ if (!id) {
984
+ warn(
985
+ false,
986
+ `Drilldown.Option without id won't be rendered. It is needed to internally track the options.`
987
+ )
988
+ return null
989
+ }
990
+
991
+ if (this.getPageChildById(id)) {
992
+ warn(
993
+ false,
994
+ `Duplicate id: "${id}"! Make sure all options have unique ids, otherwise they won't be rendered.`
995
+ )
996
+ return null
997
+ }
998
+
999
+ // Props passed to the Option.Item
1000
+ let optionProps: Partial<OptionsItemProps> = {
1001
+ // passthrough props
1002
+ ...omitProps(option.props, [
1003
+ ...DrilldownOption.allowedProps,
1004
+ ...Options.Item.allowedProps
1005
+ ]),
1006
+ // props from selectable
1007
+ ...getOptionProps({
1008
+ id,
1009
+ // aria-selected is only valid for these roles, otherwise we need to unset it
1010
+ ...(role &&
1011
+ ![
1012
+ 'gridcell',
1013
+ 'option',
1014
+ 'row',
1015
+ 'tab',
1016
+ 'columnheader',
1017
+ 'rowheader',
1018
+ 'treeitem'
1019
+ ].includes(role) && {
1020
+ 'aria-selected': undefined
1021
+ })
1022
+ }),
1023
+ // we pass the themeOverride to Options.Item
1024
+ themeOverride,
1025
+ // directly passed props
1026
+ renderBeforeLabel,
1027
+ renderAfterLabel,
1028
+ beforeLabelContentVAlign,
1029
+ afterLabelContentVAlign,
1030
+ description,
1031
+ descriptionRole,
1032
+ as,
1033
+ role,
1034
+ elementRef,
1035
+ variant: 'default',
1036
+ tabIndex: -1
1037
+ }
1038
+
1039
+ // extra data we need track on the option,
1040
+ // but don't want to expose as props
1041
+ const optionData: OptionData = {
1042
+ groupProps: groupProps
1043
+ }
1044
+
1045
+ const isOptionDisabled =
1046
+ id !== this._headerBackId && // Back nav is never disabled
1047
+ (this.props.disabled ||
1048
+ this.currentPage?.disabled ||
1049
+ groupProps?.disabled ||
1050
+ disabled)
1051
+
1052
+ // display option as disabled
1053
+ if (isOptionDisabled) {
1054
+ optionProps.variant = 'disabled'
1055
+ optionProps = { ...optionProps, ...getDisabledOptionProps() }
1056
+ }
1057
+
1058
+ // track as valid active option if not the title
1059
+ if (id !== this._headerTitleId) {
1060
+ this._activeOptionsMap[id] = { ...option, ...optionData }
1061
+ }
1062
+
1063
+ const customRole =
1064
+ role !== DrilldownOption.defaultProps.role ? role : undefined
1065
+
1066
+ // BEFORE/AFTER elements:
1067
+ // we set a few manually on Options.Item,
1068
+ // the rest are passed directly
1069
+ if (subPageId) {
1070
+ optionProps.renderAfterLabel = <IconArrowOpenEndSolid />
1071
+ optionProps['aria-haspopup'] = true
1072
+ optionProps.role = customRole || 'button'
1073
+ warn(
1074
+ !renderAfterLabel,
1075
+ `The prop "renderAfterLabel" is reserved on item with id: "${id}". When it has "subPageId" provided, a navigation arrow will render after the label.`
1076
+ )
1077
+ }
1078
+ if (id === this._headerBackId) {
1079
+ optionProps.renderBeforeLabel = <IconArrowOpenStartSolid />
1080
+ }
1081
+ if (groupProps?.selectableType) {
1082
+ isSelected = this._selectedGroupOptionsMap[groupProps.id].has(id)
1083
+ optionProps['aria-checked'] = isSelected
1084
+
1085
+ optionProps.renderBeforeLabel = (
1086
+ <IconCheckSolid
1087
+ style={{
1088
+ opacity: isSelected ? 1 : 0
1089
+ }}
1090
+ />
1091
+ )
1092
+ warn(
1093
+ !renderBeforeLabel,
1094
+ `The prop "renderBeforeLabel" is reserved on item with id: "${id}". When this option is a selectable member of a Drilldown.Group, selection indicator will render before the label.`
1095
+ )
1096
+
1097
+ // setting aria roles and attributes for selectable group items
1098
+ if (groupProps.selectableType === 'single') {
1099
+ optionProps.role = customRole || 'menuitemradio'
1100
+ }
1101
+ if (groupProps.selectableType === 'multiple') {
1102
+ optionProps.role = customRole || 'menuitemcheckbox'
1103
+ }
1104
+ }
1105
+
1106
+ // display option as highlighted
1107
+ if (id === this.state.highlightedOptionId) {
1108
+ optionProps.variant = 'highlighted'
1109
+
1110
+ if (isOptionDisabled) {
1111
+ optionProps.variant = 'highlighted-disabled'
1112
+ }
1113
+ }
1114
+
1115
+ if (href) {
1116
+ if (subPageId) {
1117
+ warn(
1118
+ false,
1119
+ `Drilldown.Option with id "${id}" has subPageId, so it will ignore the "href" property.`
1120
+ )
1121
+ } else if (groupProps?.selectableType) {
1122
+ warn(
1123
+ false,
1124
+ `Drilldown.Option with id "${id}" is in a selectable group, so it will ignore the "href" property.`
1125
+ )
1126
+ } else {
1127
+ optionProps.href = href
1128
+ }
1129
+ }
1130
+
1131
+ const optionLabel = callRenderProp(children, {
1132
+ id,
1133
+ variant: optionProps.variant,
1134
+ isSelected
1135
+ })
1136
+
1137
+ if (!optionLabel) {
1138
+ warn(
1139
+ false,
1140
+ `There are no "children" prop provided for option with id: "${id}", so it won't be rendered.`
1141
+ )
1142
+ return null
1143
+ }
1144
+
1145
+ const renderLabelProps = {
1146
+ variant: optionProps.variant,
1147
+ vAlign: afterLabelContentVAlign,
1148
+ as,
1149
+ role: optionProps.role,
1150
+ isSelected
1151
+ }
1152
+
1153
+ // we need to bind our own option props the render functions
1154
+ if (
1155
+ typeof optionProps.renderBeforeLabel === 'function' &&
1156
+ !optionProps.renderBeforeLabel?.prototype?.isReactComponent
1157
+ ) {
1158
+ optionProps.renderBeforeLabel = optionProps.renderBeforeLabel.bind(
1159
+ null,
1160
+ renderLabelProps
1161
+ )
1162
+ }
1163
+ if (
1164
+ typeof optionProps.renderAfterLabel === 'function' &&
1165
+ !optionProps.renderAfterLabel?.prototype?.isReactComponent
1166
+ ) {
1167
+ optionProps.renderAfterLabel = optionProps.renderAfterLabel.bind(
1168
+ null,
1169
+ renderLabelProps
1170
+ )
1171
+ }
1172
+
1173
+ const labelInfo =
1174
+ renderLabelInfo && callRenderProp(renderLabelInfo, renderLabelProps)
1175
+
1176
+ const vAlignMap = {
1177
+ start: 'flex-start',
1178
+ center: 'center',
1179
+ end: 'flex-end'
1180
+ }
1181
+
1182
+ const labelAriaId = `${id}__label`
1183
+ const infoAriaId = `${id}__info`
1184
+
1185
+ const labelledby = option.props['aria-labelledby'] || labelAriaId
1186
+ const describedby =
1187
+ option.props['aria-describedby'] || (labelInfo ? infoAriaId : undefined)
1188
+
1189
+ return (
1190
+ <Options.Item
1191
+ {...optionProps}
1192
+ key={id}
1193
+ aria-labelledby={labelledby}
1194
+ aria-describedby={describedby}
1195
+ >
1196
+ <div css={styles?.optionContainer} role="none">
1197
+ <span css={styles?.optionContent} role="none" id={labelAriaId}>
1198
+ {optionLabel}
1199
+ </span>
1200
+
1201
+ {labelInfo ? (
1202
+ <span
1203
+ css={styles?.optionLabelInfo}
1204
+ role="presentation"
1205
+ style={{ alignSelf: vAlignMap[afterLabelContentVAlign!] }}
1206
+ >
1207
+ {/* this container span is needed for correct vAlign */}
1208
+ <span role="note" id={infoAriaId}>
1209
+ {labelInfo}
1210
+ </span>
1211
+ </span>
1212
+ ) : null}
1213
+ </div>
1214
+ </Options.Item>
1215
+ )
1216
+ }
1217
+
1218
+ renderGroup(
1219
+ group: GroupChild,
1220
+ getOptionProps: SelectableRender['getOptionProps'],
1221
+ getDisabledOptionProps: SelectableRender['getDisabledOptionProps'],
1222
+ needsFirstSeparator: boolean,
1223
+ needsLastSeparator: boolean
1224
+ ) {
1225
+ const {
1226
+ id,
1227
+ children,
1228
+ renderGroupTitle,
1229
+ themeOverride,
1230
+ selectableType,
1231
+ role,
1232
+ as,
1233
+ elementRef
1234
+ } = group.props
1235
+
1236
+ if (!children) {
1237
+ return null
1238
+ }
1239
+
1240
+ const groupChildren: (
1241
+ | React.ReactElement<OptionsProps>
1242
+ | React.ReactElement<OptionsSeparatorProps>
1243
+ )[] = []
1244
+
1245
+ // add a separator above
1246
+ if (needsFirstSeparator) {
1247
+ groupChildren.push(<Options.Separator />)
1248
+ }
1249
+
1250
+ if (selectableType && !this._selectedGroupOptionsMap[id]) {
1251
+ this._selectedGroupOptionsMap[id] = new Map()
1252
+ }
1253
+
1254
+ // create a sublist as a group
1255
+ // (a wrapping list item will be created by Options)
1256
+ groupChildren.push(
1257
+ <Options
1258
+ id={id}
1259
+ key={id}
1260
+ role={role}
1261
+ as={as || this.props.as}
1262
+ renderLabel={renderGroupTitle}
1263
+ elementRef={elementRef}
1264
+ // we pass the themeOverride to Options
1265
+ themeOverride={themeOverride}
1266
+ >
1267
+ {this.getChildrenArray(children).map((child) => {
1268
+ if (
1269
+ matchComponentTypes<SeparatorChild>(child, [DrilldownSeparator])
1270
+ ) {
1271
+ return this.renderSeparator(child)
1272
+ } else if (
1273
+ matchComponentTypes<OptionChild>(child, [DrilldownOption])
1274
+ ) {
1275
+ return this.renderOption(
1276
+ child,
1277
+ getOptionProps,
1278
+ getDisabledOptionProps,
1279
+ group.props
1280
+ )
1281
+ } else {
1282
+ return null
1283
+ }
1284
+ })}
1285
+ </Options>
1286
+ )
1287
+
1288
+ // add a separator below
1289
+ if (needsLastSeparator) {
1290
+ groupChildren.push(<Options.Separator />)
1291
+ }
1292
+
1293
+ return groupChildren
1294
+ }
1295
+
1296
+ renderPage() {
1297
+ const {
1298
+ styles,
1299
+ overflowY,
1300
+ overflowX,
1301
+ height,
1302
+ width,
1303
+ maxHeight,
1304
+ maxWidth,
1305
+ role,
1306
+ as,
1307
+ label
1308
+ } = this.props
1309
+
1310
+ if (!this.currentPage) {
1311
+ return null
1312
+ }
1313
+
1314
+ return (
1315
+ <Selectable
1316
+ isShowingOptions={this.shown}
1317
+ highlightedOptionId={this.state.highlightedOptionId}
1318
+ selectedOptionId={this.selectedGroupOptionIdsArray}
1319
+ onRequestShowOptions={this.show}
1320
+ onRequestHideOptions={this.hide}
1321
+ onRequestSelectOption={this.handleOptionSelect}
1322
+ onRequestHighlightOption={this.handleOptionHighlight}
1323
+ onRequestHighlightFirstOption={(event) => {
1324
+ const firstOptionId = this.activeOptionIds[0]
1325
+ this.handleOptionHighlight(event, { id: firstOptionId })
1326
+ }}
1327
+ onRequestHighlightLastOption={(event) => {
1328
+ const lastOptionId =
1329
+ this.activeOptionIds[this.activeOptionIds.length - 1]
1330
+ this.handleOptionHighlight(event, { id: lastOptionId })
1331
+ }}
1332
+ >
1333
+ {({
1334
+ // TODO: figure out what other Selectable props we need, if we want to add a Select version for drilldown:
1335
+ // getRootProps, - we probably don't need this
1336
+ // getLabelProps, - do we need label?
1337
+ // getDescriptionProps, - might be nice for assistiveText like in Select
1338
+ // getInputProps, - hidden input for a11y? role="combobox" might be needed
1339
+ getTriggerProps,
1340
+ getListProps,
1341
+ getOptionProps,
1342
+ getDisabledOptionProps
1343
+ }) => (
1344
+ <View
1345
+ as="div"
1346
+ elementRef={this.handleDrilldownRef}
1347
+ tabIndex={0}
1348
+ css={styles?.drilldown}
1349
+ position="relative"
1350
+ borderRadius="small"
1351
+ width={width}
1352
+ maxWidth={maxWidth}
1353
+ role={role}
1354
+ aria-label={label}
1355
+ aria-labelledby={this.currentPageAriaLabel}
1356
+ {...getTriggerProps({
1357
+ id: this._id,
1358
+ // We need to override these aria attributes added by Selectable,
1359
+ // since Drilldown is not a combobox and has no popup
1360
+ 'aria-haspopup': false,
1361
+ 'aria-expanded': undefined,
1362
+ onKeyDown: this.handleKeyDown,
1363
+ onBlur: (event: React.FocusEvent) => {
1364
+ const target = event.currentTarget
1365
+ const related = event.relatedTarget
1366
+ const containsRelated = contains(
1367
+ target as Node | Window,
1368
+ related as Node | Window
1369
+ )
1370
+
1371
+ if (
1372
+ !related ||
1373
+ related === this._drilldownRef ||
1374
+ (related !== target && !containsRelated)
1375
+ ) {
1376
+ this.setState({ highlightedOptionId: undefined })
1377
+ }
1378
+ },
1379
+ onMouseLeave: () => {
1380
+ this.setState({ highlightedOptionId: undefined })
1381
+ }
1382
+ })}
1383
+ >
1384
+ <View
1385
+ as="div"
1386
+ overflowY={overflowY}
1387
+ overflowX={overflowX}
1388
+ height={height}
1389
+ width={width}
1390
+ maxHeight={maxHeight}
1391
+ maxWidth={maxWidth}
1392
+ css={styles?.container}
1393
+ borderRadius="small"
1394
+ role="presentation"
1395
+ elementRef={(element) => {
1396
+ this._containerElement = element as HTMLDivElement
1397
+ }}
1398
+ >
1399
+ <Options {...getListProps()} role="presentation" as={as}>
1400
+ {this.renderList(getOptionProps, getDisabledOptionProps)}
1401
+ </Options>
1402
+ </View>
1403
+ </View>
1404
+ )}
1405
+ </Selectable>
1406
+ )
1407
+ }
1408
+
1409
+ render() {
1410
+ // clear temporary option store
1411
+ this._activeOptionsMap = {}
1412
+
1413
+ const {
1414
+ show,
1415
+ defaultShow,
1416
+ placement,
1417
+ withArrow,
1418
+ shouldContainFocus,
1419
+ shouldReturnFocus,
1420
+ trigger,
1421
+ mountNode,
1422
+ constrain,
1423
+ positionTarget,
1424
+ popoverRef,
1425
+ disabled,
1426
+ onDismiss,
1427
+ onFocus,
1428
+ onMouseOver,
1429
+ offsetX,
1430
+ offsetY
1431
+ } = this.props
1432
+
1433
+ return trigger ? (
1434
+ <Popover
1435
+ isShowingContent={show}
1436
+ defaultIsShowingContent={defaultShow}
1437
+ shouldCloseOnDocumentClick={true}
1438
+ onHideContent={(event, { documentClick }) => {
1439
+ if (typeof onDismiss === 'function') {
1440
+ onDismiss(event, documentClick)
1441
+ }
1442
+ this.reset()
1443
+ this.handleToggle(event, false)
1444
+ }}
1445
+ onShowContent={(event) => this.handleToggle(event, true)}
1446
+ mountNode={mountNode}
1447
+ placement={placement}
1448
+ withArrow={withArrow}
1449
+ positionTarget={positionTarget}
1450
+ constrain={constrain}
1451
+ shouldContainFocus={shouldContainFocus}
1452
+ shouldReturnFocus={shouldReturnFocus}
1453
+ id={this._id}
1454
+ on={['click']}
1455
+ onFocus={onFocus}
1456
+ onMouseOver={onMouseOver}
1457
+ offsetX={offsetX}
1458
+ offsetY={offsetY}
1459
+ elementRef={(element) => {
1460
+ // setting ref for "Popover" version, the popover root
1461
+ // (if there is no trigger, we set it in handleDrilldownRef)
1462
+ this.handleRef(element)
1463
+ }}
1464
+ ref={(el) => {
1465
+ this._popover = el
1466
+ if (typeof popoverRef === 'function') {
1467
+ popoverRef(el)
1468
+ }
1469
+ }}
1470
+ renderTrigger={safeCloneElement(trigger as ReactElement, {
1471
+ ref: (el: (React.ReactInstance & { ref?: Element }) | null) => {
1472
+ this._trigger = el
1473
+ },
1474
+ 'aria-haspopup': this.props.role,
1475
+ id: this._triggerId,
1476
+ disabled: !!((trigger as ReactElement).props.disabled || disabled),
1477
+ 'aria-disabled':
1478
+ (trigger as ReactElement).props.disabled || disabled
1479
+ ? 'true'
1480
+ : undefined
1481
+ })}
1482
+ >
1483
+ {this.renderPage()}
1484
+ </Popover>
1485
+ ) : (
1486
+ this.renderPage()
1487
+ )
1488
+ }
1489
+ }
1490
+
1491
+ export default Drilldown
1492
+ export { Drilldown }