@instructure/ui-drilldown 11.0.1 → 11.0.2-snapshot-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,7 @@ import { Children, Component, ReactElement, ReactNode } from 'react'
26
26
 
27
27
  import { warn, error } from '@instructure/console'
28
28
  import { contains, containsActiveElement } from '@instructure/ui-dom-utils'
29
+ import { deepEqual } from '@instructure/ui-utils'
29
30
  import {
30
31
  callRenderProp,
31
32
  matchComponentTypes,
@@ -54,6 +55,7 @@ import { withStyle } from '@instructure/emotion'
54
55
 
55
56
  import { DrilldownSeparator } from './DrilldownSeparator'
56
57
  import { DrilldownOption } from './DrilldownOption'
58
+ import type { DrilldownOptionValue } from './DrilldownOption/props'
57
59
  import { DrilldownGroup } from './DrilldownGroup'
58
60
  import type { DrilldownGroupProps, GroupChildren } from './DrilldownGroup/props'
59
61
  import { DrilldownPage } from './DrilldownPage'
@@ -147,8 +149,6 @@ class Drilldown extends Component<DrilldownProps, DrilldownState> {
147
149
  [optionId: string]: MappedOption
148
150
  } = {}
149
151
 
150
- // Map of the selected options in groups (grouped by group id)
151
-
152
152
  ref: HTMLDivElement | Element | null = null
153
153
 
154
154
  handleRef = (el: HTMLDivElement | Element | null) => {
@@ -186,7 +186,7 @@ class Drilldown extends Component<DrilldownProps, DrilldownState> {
186
186
  activePageId: props.rootPageId,
187
187
  highlightedOptionId: undefined,
188
188
  lastSelectedId: undefined,
189
- selectedGroupOptionsMap: this.setDefaultSelected()
189
+ selectedGroupOptionsMap: this.getSelectedGroupOptionsMap()
190
190
  }
191
191
  this._pageHistory = [props.rootPageId]
192
192
 
@@ -206,7 +206,6 @@ class Drilldown extends Component<DrilldownProps, DrilldownState> {
206
206
 
207
207
  componentDidUpdate(_prevProps: DrilldownProps, prevState: DrilldownState) {
208
208
  this.props.makeStyles?.(this.makeStylesVariables)
209
-
210
209
  if (prevState.activePageId !== this.state.activePageId) {
211
210
  // on page change with mouse navigation, some option can get rendered
212
211
  // under the cursor and get focused, so we need to wait a tick to see
@@ -235,6 +234,57 @@ class Drilldown extends Component<DrilldownProps, DrilldownState> {
235
234
  highlightedOptionId: undefined
236
235
  })
237
236
  }
237
+
238
+ // This block syncs the internal state if the selectedOptions prop changes
239
+ // selectedOptions prop values always win over internal state.
240
+ let shouldUpdateState = false
241
+ const nextSelectionMap: SelectedGroupOptionsMap = {
242
+ ...this.state.selectedGroupOptionsMap
243
+ }
244
+
245
+ this.pages.forEach((page) => {
246
+ this.getChildrenArray(page.props.children).forEach((child) => {
247
+ if (matchComponentTypes<GroupChild>(child, [DrilldownGroup])) {
248
+ const {
249
+ id: groupId,
250
+ selectableType,
251
+ selectedOptions,
252
+ children: groupChildren
253
+ } = child.props
254
+
255
+ if (!selectableType || !Array.isArray(selectedOptions)) return
256
+
257
+ const newGroupMap = new Map()
258
+
259
+ this.getChildrenArray(groupChildren)?.forEach((groupChild) => {
260
+ if (
261
+ matchComponentTypes<OptionChild>(groupChild, [DrilldownOption])
262
+ ) {
263
+ const { id: optionId, value: optionValue } = groupChild.props
264
+ if (selectedOptions.includes(optionValue)) {
265
+ newGroupMap.set(optionId, optionValue)
266
+ }
267
+ }
268
+ })
269
+
270
+ const currentGroupMapInState =
271
+ this.state.selectedGroupOptionsMap[groupId] || new Map()
272
+
273
+ if (
274
+ !deepEqual(
275
+ this.getNormalizedMap(newGroupMap),
276
+ this.getNormalizedMap(currentGroupMapInState)
277
+ )
278
+ ) {
279
+ nextSelectionMap[groupId] = newGroupMap
280
+ shouldUpdateState = true
281
+ }
282
+ }
283
+ })
284
+ })
285
+ if (shouldUpdateState) {
286
+ this.setState({ selectedGroupOptionsMap: nextSelectionMap })
287
+ }
238
288
  }
239
289
 
240
290
  get makeStylesVariables(): DrilldownStyleProps {
@@ -346,9 +396,30 @@ class Drilldown extends Component<DrilldownProps, DrilldownState> {
346
396
  return id ? this._activeOptionsMap[id] : undefined
347
397
  }
348
398
 
349
- setDefaultSelected() {
350
- const selectedGroupOptionsMap: SelectedGroupOptionsMap = {}
399
+ getNormalizedMap(map: Map<string, DrilldownOptionValue>) {
400
+ return Array.from(map.entries())
401
+ }
351
402
 
403
+ /**
404
+ * Initializes the map of selected options for each group on initial render.
405
+ *
406
+ * This function establishes the selection state based on a clear precedence:
407
+ * 1. **Controlled Mode**: If a `Drilldown.Group` has the `selectedOptions` prop
408
+ * (as an array), it is treated as the absolute source of truth.
409
+ *
410
+ * 2. **Uncontrolled Mode**: If `selectedOptions` is not provided, the selection
411
+ * is determined by `defaultSelected` props, with the following priority:
412
+ * a. The `defaultSelected` boolean on an individual `Drilldown.Option`.
413
+ * b. The `defaultSelected` array of values on the `Drilldown.Group`.
414
+ *
415
+ * It also validates that 'single' selection groups do not mistakenly receive
416
+ * multiple selected or default values.
417
+ *
418
+ * @returns {SelectedGroupOptionsMap} An object where keys are group IDs and
419
+ * values are Maps of the selected { optionId: optionValue } pairs for that group.
420
+ */
421
+ getSelectedGroupOptionsMap() {
422
+ const selectedGroupOptionsMap: SelectedGroupOptionsMap = {}
352
423
  this.pages.forEach((page) => {
353
424
  const { children } = page.props
354
425
 
@@ -357,31 +428,36 @@ class Drilldown extends Component<DrilldownProps, DrilldownState> {
357
428
  const {
358
429
  id: groupId,
359
430
  selectableType,
360
- defaultSelected: groupDefaultSelected = [],
431
+ selectedOptions,
432
+ defaultSelected = [],
361
433
  children: groupChildren
362
434
  } = child.props
363
435
 
364
- if (!selectableType) {
365
- return
366
- }
367
-
368
- if (selectableType && !selectedGroupOptionsMap[groupId]) {
369
- selectedGroupOptionsMap[groupId] = new Map()
370
- }
436
+ if (!selectableType) return
371
437
 
372
- if (selectableType === 'single' && groupDefaultSelected.length > 1) {
373
- error(
374
- false,
375
- `Radio type selectable groups can have only one item selected! Group with id "${groupId}" cannot select multiple items: [${groupDefaultSelected.join(
376
- ', '
377
- )}]!`
378
- )
438
+ selectedGroupOptionsMap[groupId] = new Map()
379
439
 
380
- return
440
+ if (selectableType === 'single') {
441
+ if (Array.isArray(selectedOptions) && selectedOptions.length > 1) {
442
+ error(
443
+ false,
444
+ `Radio type selectable groups can have only one item selected! Group "${groupId}" has multiple values: [${selectedOptions.join(
445
+ ', '
446
+ )}]!`
447
+ )
448
+ return
449
+ }
450
+ if (defaultSelected.length > 1) {
451
+ error(
452
+ false,
453
+ `Radio type selectable groups can have only one default item selected! Group "${groupId}" has multiple values: [${defaultSelected.join(
454
+ ', '
455
+ )}]!`
456
+ )
457
+ return
458
+ }
381
459
  }
382
460
 
383
- selectedGroupOptionsMap[groupId] = new Map()
384
-
385
461
  this.getChildrenArray(groupChildren)?.forEach((groupChild) => {
386
462
  if (
387
463
  matchComponentTypes<OptionChild>(groupChild, [DrilldownOption])
@@ -392,18 +468,24 @@ class Drilldown extends Component<DrilldownProps, DrilldownState> {
392
468
  defaultSelected: optionDefaultSelected
393
469
  } = groupChild.props
394
470
 
395
- // if explicitly set to false, it should override the group's
396
- if (optionDefaultSelected === false) {
397
- return
471
+ if (optionValue == null) return
472
+
473
+ let isSelected = false
474
+ // If the group is controlled via the `selectedOptions` prop, it is the source of truth.
475
+ // `defaultSelected` values are ignored.
476
+ if (Array.isArray(selectedOptions)) {
477
+ isSelected = selectedOptions.includes(optionValue)
478
+ // Second strongest is the `defaultSelected` value on the option itself.
479
+ } else if (optionDefaultSelected === false) {
480
+ isSelected = false
481
+ } else {
482
+ // Last is the `defaultSelected` array on the group.
483
+ const isGroupDefaultSelected =
484
+ defaultSelected.includes(optionValue)
485
+ isSelected = optionDefaultSelected || isGroupDefaultSelected
398
486
  }
399
487
 
400
- const isGroupDefaultSelected =
401
- typeof optionValue !== 'undefined' &&
402
- groupDefaultSelected
403
- .filter((value) => typeof value !== 'undefined')
404
- .includes(optionValue)
405
-
406
- if (optionDefaultSelected || isGroupDefaultSelected) {
488
+ if (isSelected) {
407
489
  selectedGroupOptionsMap[groupId].set(optionId, optionValue)
408
490
  }
409
491
  }