@instructure/ui-tabs 11.7.10 → 11.7.11-snapshot-4

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.
@@ -41,8 +41,8 @@ import {
41
41
  withDeterministicId
42
42
  } from '@instructure/ui-react-utils'
43
43
  import { logError as error } from '@instructure/console'
44
- import { Focusable } from '@instructure/ui-focusable'
45
- import { getBoundingClientRect } from '@instructure/ui-dom-utils'
44
+ import { textDirectionContextConsumer } from '@instructure/ui-i18n'
45
+ import { contains, getBoundingClientRect } from '@instructure/ui-dom-utils'
46
46
  import type { RectType } from '@instructure/ui-dom-utils'
47
47
  import { debounce } from '@instructure/debounce'
48
48
  import type { Debounced } from '@instructure/debounce'
@@ -64,6 +64,15 @@ import type { TabsPanelProps } from './Panel/props'
64
64
  type TabChild = ComponentElement<TabsTabProps, any>
65
65
  type PanelChild = ComponentElement<TabsPanelProps, Panel>
66
66
 
67
+ type TabDescriptor = {
68
+ index: number
69
+ tabDomId: string
70
+ panelDomId: string
71
+ isDisabled: boolean
72
+ isSelected: boolean
73
+ panel: PanelChild
74
+ }
75
+
67
76
  /**
68
77
  ---
69
78
  category: components
@@ -71,6 +80,7 @@ category: components
71
80
  **/
72
81
  @withDeterministicId()
73
82
  @withStyleNew(generateStyle)
83
+ @textDirectionContextConsumer()
74
84
  class Tabs extends Component<TabsProps, TabsState> {
75
85
  static displayName = 'Tabs'
76
86
  static readonly componentId = 'Tabs'
@@ -80,14 +90,14 @@ class Tabs extends Component<TabsProps, TabsState> {
80
90
  static defaultProps = {
81
91
  variant: 'default',
82
92
  shouldFocusOnRender: false,
83
- tabOverflow: 'stack'
93
+ tabOverflow: 'stack',
94
+ activationMode: 'auto'
84
95
  }
85
96
 
86
97
  static Panel = Panel
87
98
  static Tab = Tab
88
99
 
89
100
  private _tabList: Element | null = null
90
- private _focusable: Focusable | null = null
91
101
  private _tabListPosition?: RectType
92
102
  private _debounced?: Debounced<typeof this.handleResize>
93
103
  private _resizeListener?: ResizeObserver
@@ -252,30 +262,74 @@ class Tabs extends Component<TabsProps, TabsState> {
252
262
  }
253
263
  }
254
264
 
265
+ get isRtl() {
266
+ return this.props.dir === textDirectionContextConsumer.DIRECTION.rtl
267
+ }
268
+
255
269
  handleTabClick: TabsTabProps['onClick'] = (event, { index }) => {
256
- const nextTab = this.getNextTab(index, 0)
257
- this.fireOnChange(event, nextTab)
270
+ const target = this.getNextTab(this.getTabs(), index, 0)
271
+
272
+ if (target) {
273
+ this.fireOnChange(event, target)
274
+ }
258
275
  }
259
276
 
260
277
  handleTabKeyDown: TabsTabProps['onKeyDown'] = (event, { index }) => {
261
- let nextTab
278
+ const tabs = this.getTabs()
279
+ let target: TabDescriptor | undefined
280
+ let isActivation = false
281
+
282
+ switch (event.keyCode) {
283
+ case keycode.codes.left:
284
+ target = this.getNextTab(tabs, index, this.isRtl ? 1 : -1)
285
+ break
286
+ case keycode.codes.right:
287
+ target = this.getNextTab(tabs, index, this.isRtl ? -1 : 1)
288
+ break
289
+ case keycode.codes.up:
290
+ target = this.getNextTab(tabs, index, -1)
291
+ break
292
+ case keycode.codes.down:
293
+ target = this.getNextTab(tabs, index, 1)
294
+ break
295
+ case keycode.codes.home:
296
+ target = this.getEdgeTab(tabs, 'first')
297
+ break
298
+ case keycode.codes.end:
299
+ target = this.getEdgeTab(tabs, 'last')
300
+ break
301
+ case keycode.codes.enter:
302
+ case keycode.codes.space:
303
+ target = this.getNextTab(tabs, index, 0)
304
+ isActivation = true
305
+ break
306
+ default:
307
+ return
308
+ }
262
309
 
263
- if (
264
- event.keyCode === keycode.codes.up ||
265
- event.keyCode === keycode.codes.left
266
- ) {
267
- // Select next tab to the left
268
- nextTab = this.getNextTab(index, -1)
269
- } else if (
270
- event.keyCode === keycode.codes.down ||
271
- event.keyCode === keycode.codes.right
272
- ) {
273
- // Select next tab to the right
274
- nextTab = this.getNextTab(index, 1)
310
+ event.stopPropagation()
311
+ event.preventDefault()
312
+
313
+ if (!target) {
314
+ return
275
315
  }
276
- if (nextTab) {
277
- event.preventDefault()
278
- this.fireOnChange(event, nextTab)
316
+
317
+ if (isActivation || this.props.activationMode !== 'manual') {
318
+ this.fireOnChange(event, target)
319
+ }
320
+
321
+ this.focusTab(target.tabDomId)
322
+ }
323
+
324
+ handleTabFocus: TabsTabProps['onFocus'] = (_event, { id }) => {
325
+ if (this.state.focusedTabId !== id) {
326
+ this.setState({ focusedTabId: id })
327
+ }
328
+ }
329
+
330
+ handleTabListBlur = (event: React.FocusEvent) => {
331
+ if (!contains(event.currentTarget, event.relatedTarget)) {
332
+ this.setState({ focusedTabId: undefined })
279
333
  }
280
334
  }
281
335
 
@@ -288,75 +342,129 @@ class Tabs extends Component<TabsProps, TabsState> {
288
342
  this._tabListPosition = getBoundingClientRect(this._tabList)
289
343
  }
290
344
 
345
+ /**
346
+ * The single source of truth for tab order, ids and state.
347
+ */
348
+ getTabs(): TabDescriptor[] {
349
+ const panels: { index: number; panel: PanelChild }[] = []
350
+
351
+ Children.toArray(this.props.children).forEach((child, index) => {
352
+ if (matchComponentTypes<PanelChild>(child, [Panel])) {
353
+ panels.push({ index, panel: child as PanelChild })
354
+ }
355
+ })
356
+
357
+ const explicitlySelected = panels.find(
358
+ ({ panel }) => panel.props.isSelected && !panel.props.isDisabled
359
+ )
360
+ const fallbackIndex = explicitlySelected ? explicitlySelected.index : 0
361
+
362
+ return panels.map(({ index, panel }) => {
363
+ const baseId =
364
+ panel.props.id || this.props.deterministicId!(`Tabs_${index}`)
365
+ const isDisabled = !!panel.props.isDisabled
366
+
367
+ return {
368
+ index,
369
+ tabDomId: `tab-${baseId}`,
370
+ panelDomId: panel.props.id || `panel-${baseId}`,
371
+ isDisabled,
372
+ isSelected:
373
+ !isDisabled && (!!panel.props.isSelected || index === fallbackIndex),
374
+ panel
375
+ }
376
+ })
377
+ }
378
+
291
379
  getNextTab(
380
+ tabs: TabDescriptor[],
292
381
  startIndex: number,
293
382
  step: -1 | 0 | 1
294
- ): { index: number; id?: string } {
295
- const tabs = Children.toArray(this.props.children).map(
296
- (child) => matchComponentTypes<PanelChild>(child, [Panel]) && child
297
- ) as PanelChild[]
383
+ ): TabDescriptor | undefined {
298
384
  const count = tabs.length
299
- const change = step < 0 ? step + count : step
385
+ const from = tabs.findIndex((tab) => tab.index === startIndex)
300
386
 
301
- error(
302
- startIndex >= 0 && startIndex < count,
303
- `[Tabs] Invalid tab index: '${startIndex}'.`
304
- )
387
+ error(from >= 0, `[Tabs] Invalid tab index: '${startIndex}'.`)
305
388
 
306
- let nextIndex = startIndex
307
- let nextTab: PanelChild
389
+ if (from < 0) {
390
+ return undefined
391
+ }
392
+
393
+ if (step === 0) {
394
+ return tabs[from]
395
+ }
396
+
397
+ for (let offset = 1; offset <= count; offset++) {
398
+ const candidate = tabs[(((from + step * offset) % count) + count) % count]
399
+
400
+ if (!candidate.isDisabled) {
401
+ return candidate
402
+ }
403
+ }
404
+
405
+ return undefined
406
+ }
407
+
408
+ getEdgeTab(
409
+ tabs: TabDescriptor[],
410
+ edge: 'first' | 'last'
411
+ ): TabDescriptor | undefined {
412
+ const enabled = tabs.filter((tab) => !tab.isDisabled)
413
+
414
+ return edge === 'first' ? enabled[0] : enabled[enabled.length - 1]
415
+ }
308
416
 
309
- do {
310
- nextIndex = (nextIndex + change) % count
311
- nextTab = tabs[nextIndex]
312
- } while (nextTab && nextTab.props && nextTab.props.isDisabled)
417
+ getRovingTabId(tabs: TabDescriptor[]): string | undefined {
418
+ const { focusedTabId } = this.state
419
+ const focused = tabs.find(
420
+ (tab) => tab.tabDomId === focusedTabId && !tab.isDisabled
421
+ )
313
422
 
314
- error(
315
- nextIndex >= 0 && nextIndex < count,
316
- `[Tabs] Invalid tab index: '${nextIndex}'.`
423
+ return (
424
+ focused?.tabDomId ??
425
+ tabs.find((tab) => tab.isSelected)?.tabDomId ??
426
+ tabs.find((tab) => !tab.isDisabled)?.tabDomId
317
427
  )
318
- return { index: nextIndex, id: nextTab.props.id }
319
428
  }
320
429
 
321
430
  fireOnChange(
322
431
  event: React.MouseEvent<ViewOwnProps> | React.KeyboardEvent<ViewOwnProps>,
323
- { index, id }: { index: number; id?: string }
432
+ { index, panel, tabDomId }: TabDescriptor
324
433
  ) {
325
434
  if (typeof this.props.onRequestTabChange === 'function') {
326
- this.props.onRequestTabChange(event, { index, id })
435
+ this.props.onRequestTabChange(event, { index, id: panel.props.id })
327
436
  }
328
437
 
329
- // this is needed because keypress cancels scrolling. So we have to trigger the scrolling
438
+ this.scrollTabIntoView(tabDomId)
439
+ }
440
+
441
+ scrollTabIntoView(tabDomId: string) {
330
442
  // one "tick" later than the keypress
331
443
  setTimeout(() => {
332
444
  if (this.state.withTabListOverflow) {
333
- const tab = id
334
- ? this._tabList!.querySelector(`#tab-${CSS.escape(id)}`)
335
- : null
336
- this.showActiveTabIfOverlayed(tab)
445
+ this.showActiveTabIfOverlayed(
446
+ this._tabList!.querySelector(`#${CSS.escape(tabDomId)}`)
447
+ )
337
448
  }
338
449
  }, 0)
339
450
  }
340
451
 
341
- createTab(
342
- index: number,
343
- generatedId: string,
344
- selected: boolean,
345
- panel: PanelChild
346
- ): TabChild {
347
- const id = panel.props.id || generatedId
452
+ createTab(tab: TabDescriptor, isTabbable: boolean): TabChild {
453
+ const { index, tabDomId, panelDomId, isSelected, isDisabled, panel } = tab
348
454
 
349
455
  return (
350
456
  <Tab
351
457
  variant={this.props.variant}
352
458
  key={`tab-${index}`}
353
- id={`tab-${id}`}
354
- controls={panel.props.id || `panel-${id}`}
459
+ id={tabDomId}
460
+ controls={panelDomId}
355
461
  index={index}
356
- isSelected={selected}
357
- isDisabled={panel.props.isDisabled}
462
+ isSelected={isSelected}
463
+ isDisabled={isDisabled}
464
+ isTabbable={isTabbable}
358
465
  onClick={this.handleTabClick}
359
466
  onKeyDown={this.handleTabKeyDown}
467
+ onFocus={this.handleTabFocus}
360
468
  isOverflowScroll={this.props.tabOverflow === 'scroll'}
361
469
  >
362
470
  {panel.props.renderTitle}
@@ -364,22 +472,16 @@ class Tabs extends Component<TabsProps, TabsState> {
364
472
  )
365
473
  }
366
474
 
367
- clonePanel(
368
- index: number,
369
- generatedId: string,
370
- selected: boolean,
371
- panel: PanelChild,
372
- activePanel?: PanelChild
373
- ) {
374
- const id = panel.props.id || generatedId
475
+ clonePanel(tab: TabDescriptor, activePanel?: PanelChild) {
476
+ const { index, tabDomId, panelDomId, isSelected, panel } = tab
375
477
 
376
478
  // fixHeight can be 0, so simply `fixheight` could return falsy value
377
479
  const hasFixedHeight = typeof this.props.fixHeight !== 'undefined'
378
480
 
379
481
  const commonProps = {
380
- id: panel.props.id || `panel-${id}`,
381
- labelledBy: `tab-${id}`,
382
- isSelected: selected,
482
+ id: panelDomId,
483
+ labelledBy: tabDomId,
484
+ isSelected,
383
485
  variant: this.props.variant,
384
486
  maxHeight: !hasFixedHeight ? this.props.maxHeight : undefined,
385
487
  minHeight: !hasFixedHeight ? this.props.minHeight : '100%'
@@ -408,18 +510,25 @@ class Tabs extends Component<TabsProps, TabsState> {
408
510
  }
409
511
  }
410
512
 
411
- handleFocusableRef = (el: Focusable | null) => {
412
- this._focusable = el
413
- }
414
-
415
513
  handleTabListRef = (el: Element | null) => {
416
514
  this._tabList = el
417
515
  }
418
516
 
419
517
  focus() {
420
- this._focusable &&
421
- typeof this._focusable.focus === 'function' &&
422
- this._focusable.focus()
518
+ this.focusTab(this.getRovingTabId(this.getTabs()))
519
+ }
520
+
521
+ focusTab(tabDomId?: string) {
522
+ if (!tabDomId) {
523
+ return
524
+ }
525
+
526
+ const tab = this._tabList?.querySelector<HTMLElement>(
527
+ `#${CSS.escape(tabDomId)}`
528
+ )
529
+
530
+ tab?.focus()
531
+ this.scrollTabIntoView(tabDomId)
423
532
  }
424
533
 
425
534
  handleScroll = (
@@ -456,40 +565,37 @@ class Tabs extends Component<TabsProps, TabsState> {
456
565
  onRequestTabChange,
457
566
  tabOverflow,
458
567
  styles,
568
+ dir,
459
569
  ...props
460
570
  } = this.props
461
571
 
462
- const activePanels = (Children.toArray(children) as PanelChild[])
463
- .filter((child) => matchComponentTypes<PanelChild>(child, [Panel]))
464
- .filter((child) => child.props.active)
572
+ const tabDescriptors = this.getTabs()
573
+ const rovingTabId = this.getRovingTabId(tabDescriptors)
574
+ const tabsByIndex = new Map(tabDescriptors.map((tab) => [tab.index, tab]))
575
+
576
+ const activePanels = tabDescriptors
577
+ .map((tab) => tab.panel)
578
+ .filter((panel) => panel.props.active)
465
579
 
466
580
  if (activePanels.length > 1) {
467
581
  error(false, `[Tabs] Only one Panel can be marked as active.`)
468
582
  }
469
583
 
470
- const selectedChildIndex = (Children.toArray(children) as PanelChild[])
471
- .filter((child) => matchComponentTypes<PanelChild>(child, [Panel]))
472
- .findIndex((child) => child.props.isSelected && !child.props.isDisabled)
584
+ Children.toArray(children).forEach((child, index) => {
585
+ const tab = tabsByIndex.get(index)
473
586
 
474
- const selectedIndex = selectedChildIndex >= 0 ? selectedChildIndex : 0
475
- Children.toArray(children).map((child, index) => {
476
- if (matchComponentTypes<PanelChild>(child, [Panel])) {
477
- const selected =
478
- !child.props.isDisabled &&
479
- (child.props.isSelected || selectedIndex === index)
480
- const id = this.props.deterministicId!(`Tabs_${index}`)
481
-
482
- tabs.push(this.createTab(index, id, selected, child))
483
- if (activePanels.length === 1) {
484
- panels.push(
485
- this.clonePanel(index, id, selected, child, activePanels[0])
486
- )
487
- } else {
488
- panels.push(this.clonePanel(index, id, selected, child))
489
- }
490
- } else {
587
+ if (!tab) {
491
588
  panels.push(child as PanelChild)
589
+ return
492
590
  }
591
+
592
+ tabs.push(this.createTab(tab, tab.tabDomId === rovingTabId))
593
+ panels.push(
594
+ this.clonePanel(
595
+ tab,
596
+ activePanels.length === 1 ? activePanels[0] : undefined
597
+ )
598
+ )
493
599
  })
494
600
 
495
601
  const withScrollFade =
@@ -514,30 +620,27 @@ class Tabs extends Component<TabsProps, TabsState> {
514
620
  css={styles?.container}
515
621
  data-cid="Tabs"
516
622
  >
517
- <Focusable ref={this.handleFocusableRef}>
518
- {() => (
519
- <View
520
- as="div"
521
- position="relative"
522
- borderRadius="medium"
523
- shouldAnimateFocus={false}
524
- css={styles?.tabs}
525
- >
526
- <View
527
- as="div"
528
- role="tablist"
529
- css={styles?.tabList}
530
- aria-label={screenReaderLabel}
531
- elementRef={this.handleTabListRef}
532
- onScroll={this.handleScroll}
533
- >
534
- {tabs}
535
- {withScrollFade && startScrollOverlay}
536
- {withScrollFade && endScrollOverlay}
537
- </View>
538
- </View>
539
- )}
540
- </Focusable>
623
+ <View
624
+ as="div"
625
+ position="relative"
626
+ borderRadius="medium"
627
+ shouldAnimateFocus={false}
628
+ css={styles?.tabs}
629
+ >
630
+ <View
631
+ as="div"
632
+ role="tablist"
633
+ css={styles?.tabList}
634
+ aria-label={screenReaderLabel}
635
+ elementRef={this.handleTabListRef}
636
+ onScroll={this.handleScroll}
637
+ onBlur={this.handleTabListBlur}
638
+ >
639
+ {tabs}
640
+ {withScrollFade && startScrollOverlay}
641
+ {withScrollFade && endScrollOverlay}
642
+ </View>
643
+ </View>
541
644
 
542
645
  <div css={styles?.panelsContainer}>{panels}</div>
543
646
  </View>
@@ -79,6 +79,13 @@ type TabsOwnProps = {
79
79
  */
80
80
  tabOverflow?: 'stack' | 'scroll'
81
81
  shouldFocusOnRender?: boolean
82
+ /**
83
+ * With `auto`, arrow keys select a tab as soon as focus reaches it. With
84
+ * `manual`, arrow keys only move focus and Enter or Space selects the focused
85
+ * tab. Screen readers that reserve the arrow keys for their own cursor
86
+ * (VoiceOver) need `manual`.
87
+ */
88
+ activationMode?: 'auto' | 'manual'
82
89
  }
83
90
 
84
91
  type PropKeys = keyof TabsOwnProps
@@ -107,6 +114,7 @@ type TabsState = {
107
114
  withTabListOverflow: boolean
108
115
  showStartOverLay: boolean
109
116
  showEndOverLay: boolean
117
+ focusedTabId?: string
110
118
  }
111
119
  const allowedProps: AllowedPropKeys = [
112
120
  'children',
@@ -122,7 +130,8 @@ const allowedProps: AllowedPropKeys = [
122
130
  'textAlign',
123
131
  'elementRef',
124
132
  'tabOverflow',
125
- 'shouldFocusOnRender'
133
+ 'shouldFocusOnRender',
134
+ 'activationMode'
126
135
  ]
127
136
 
128
137
  export type { TabsProps, TabsState, TabsStyle }