@instructure/ui-tabs 11.7.10-snapshot--1 → 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.
package/CHANGELOG.md CHANGED
@@ -3,7 +3,14 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
- ## [11.7.10-snapshot--1](https://github.com/instructure/instructure-ui/compare/v11.7.8...v11.7.10-snapshot--1) (2026-10-14)
6
+ ## [11.7.11-snapshot-4](https://github.com/instructure/instructure-ui/compare/v11.7.10...v11.7.11-snapshot-4) (2026-10-22)
7
+
8
+ ### Features
9
+
10
+ * **ui-tabs:** add roving tabindex and manual activation mode ([#2723](https://github.com/instructure/instructure-ui/issues/2723)) ([bc4bbdc](https://github.com/instructure/instructure-ui/commit/bc4bbdc3def4f353f8dc7b5e6ef672625e9efb1b))
11
+
12
+
13
+ ## [11.7.10](https://github.com/instructure/instructure-ui/compare/v11.7.8...v11.7.10) (2026-10-14)
7
14
 
8
15
  **Note:** Version bump only for package @instructure/ui-tabs
9
16
 
@@ -86,12 +86,30 @@ let Tab = (_dec = withStyleNew(generateStyle), _dec(_class = class Tab extends C
86
86
  });
87
87
  }
88
88
  };
89
+ handleFocus = event => {
90
+ const {
91
+ onFocus,
92
+ index,
93
+ id,
94
+ isDisabled
95
+ } = this.props;
96
+ if (isDisabled) {
97
+ return;
98
+ }
99
+ if (typeof onFocus === 'function') {
100
+ onFocus(event, {
101
+ index,
102
+ id
103
+ });
104
+ }
105
+ };
89
106
  render() {
90
107
  const {
91
108
  id,
92
109
  variant,
93
110
  isSelected,
94
111
  isDisabled,
112
+ isTabbable = isSelected,
95
113
  controls,
96
114
  children,
97
115
  styles,
@@ -104,11 +122,12 @@ let Tab = (_dec = withStyleNew(generateStyle), _dec(_class = class Tab extends C
104
122
  id: id,
105
123
  onClick: this.handleClick,
106
124
  onKeyDown: this.handleKeyDown,
125
+ onFocus: this.handleFocus,
107
126
  css: styles?.tab,
108
- "aria-selected": isSelected ? 'true' : undefined,
127
+ "aria-selected": isSelected ? 'true' : 'false',
109
128
  "aria-disabled": isDisabled ? 'true' : undefined,
110
129
  "aria-controls": controls,
111
- tabIndex: isSelected && !isDisabled ? 0 : undefined,
130
+ tabIndex: isDisabled ? undefined : isTabbable ? 0 : -1,
112
131
  position: "relative",
113
132
  focusPosition: "offset",
114
133
  children: callRenderProp(children)
@@ -22,5 +22,5 @@
22
22
  * SOFTWARE.
23
23
  */
24
24
 
25
- const allowedProps = ['variant', 'id', 'index', 'controls', 'isDisabled', 'isSelected', 'onClick', 'onKeyDown', 'children', 'isOverflowScroll'];
25
+ const allowedProps = ['variant', 'id', 'index', 'controls', 'isDisabled', 'isSelected', 'isTabbable', 'onClick', 'onKeyDown', 'onFocus', 'children', 'isOverflowScroll'];
26
26
  export { allowedProps };
@@ -1,4 +1,4 @@
1
- var _dec, _dec2, _class;
1
+ var _dec, _dec2, _dec3, _class;
2
2
  /*
3
3
  * The MIT License (MIT)
4
4
  *
@@ -28,8 +28,8 @@ import keycode from 'keycode';
28
28
  import { View } from '@instructure/ui-view/latest';
29
29
  import { matchComponentTypes, safeCloneElement, passthroughProps, withDeterministicId } from '@instructure/ui-react-utils';
30
30
  import { logError as error } from '@instructure/console';
31
- import { Focusable } from '@instructure/ui-focusable';
32
- import { getBoundingClientRect } from '@instructure/ui-dom-utils';
31
+ import { textDirectionContextConsumer } from '@instructure/ui-i18n';
32
+ import { contains, getBoundingClientRect } from '@instructure/ui-dom-utils';
33
33
  import { debounce } from '@instructure/debounce';
34
34
  import { px } from '@instructure/ui-utils';
35
35
  import { withStyleNew } from '@instructure/emotion';
@@ -43,19 +43,19 @@ import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
43
43
  category: components
44
44
  ---
45
45
  **/
46
- let Tabs = (_dec = withDeterministicId(), _dec2 = withStyleNew(generateStyle), _dec(_class = _dec2(_class = class Tabs extends Component {
46
+ let Tabs = (_dec = withDeterministicId(), _dec2 = withStyleNew(generateStyle), _dec3 = textDirectionContextConsumer(), _dec(_class = _dec2(_class = _dec3(_class = class Tabs extends Component {
47
47
  static displayName = 'Tabs';
48
48
  static componentId = 'Tabs';
49
49
  static allowedProps = allowedProps;
50
50
  static defaultProps = {
51
51
  variant: 'default',
52
52
  shouldFocusOnRender: false,
53
- tabOverflow: 'stack'
53
+ tabOverflow: 'stack',
54
+ activationMode: 'auto'
54
55
  };
55
56
  static Panel = Panel;
56
57
  static Tab = Tab;
57
58
  _tabList = null;
58
- _focusable = null;
59
59
  _tabListPosition;
60
60
  _debounced;
61
61
  _resizeListener;
@@ -185,26 +185,74 @@ let Tabs = (_dec = withDeterministicId(), _dec2 = withStyleNew(generateStyle), _
185
185
  }
186
186
  }
187
187
  }
188
+ get isRtl() {
189
+ return this.props.dir === textDirectionContextConsumer.DIRECTION.rtl;
190
+ }
188
191
  handleTabClick = (event, {
189
192
  index
190
193
  }) => {
191
- const nextTab = this.getNextTab(index, 0);
192
- this.fireOnChange(event, nextTab);
194
+ const target = this.getNextTab(this.getTabs(), index, 0);
195
+ if (target) {
196
+ this.fireOnChange(event, target);
197
+ }
193
198
  };
194
199
  handleTabKeyDown = (event, {
195
200
  index
196
201
  }) => {
197
- let nextTab;
198
- if (event.keyCode === keycode.codes.up || event.keyCode === keycode.codes.left) {
199
- // Select next tab to the left
200
- nextTab = this.getNextTab(index, -1);
201
- } else if (event.keyCode === keycode.codes.down || event.keyCode === keycode.codes.right) {
202
- // Select next tab to the right
203
- nextTab = this.getNextTab(index, 1);
202
+ const tabs = this.getTabs();
203
+ let target;
204
+ let isActivation = false;
205
+ switch (event.keyCode) {
206
+ case keycode.codes.left:
207
+ target = this.getNextTab(tabs, index, this.isRtl ? 1 : -1);
208
+ break;
209
+ case keycode.codes.right:
210
+ target = this.getNextTab(tabs, index, this.isRtl ? -1 : 1);
211
+ break;
212
+ case keycode.codes.up:
213
+ target = this.getNextTab(tabs, index, -1);
214
+ break;
215
+ case keycode.codes.down:
216
+ target = this.getNextTab(tabs, index, 1);
217
+ break;
218
+ case keycode.codes.home:
219
+ target = this.getEdgeTab(tabs, 'first');
220
+ break;
221
+ case keycode.codes.end:
222
+ target = this.getEdgeTab(tabs, 'last');
223
+ break;
224
+ case keycode.codes.enter:
225
+ case keycode.codes.space:
226
+ target = this.getNextTab(tabs, index, 0);
227
+ isActivation = true;
228
+ break;
229
+ default:
230
+ return;
204
231
  }
205
- if (nextTab) {
206
- event.preventDefault();
207
- this.fireOnChange(event, nextTab);
232
+ event.stopPropagation();
233
+ event.preventDefault();
234
+ if (!target) {
235
+ return;
236
+ }
237
+ if (isActivation || this.props.activationMode !== 'manual') {
238
+ this.fireOnChange(event, target);
239
+ }
240
+ this.focusTab(target.tabDomId);
241
+ };
242
+ handleTabFocus = (_event, {
243
+ id
244
+ }) => {
245
+ if (this.state.focusedTabId !== id) {
246
+ this.setState({
247
+ focusedTabId: id
248
+ });
249
+ }
250
+ };
251
+ handleTabListBlur = event => {
252
+ if (!contains(event.currentTarget, event.relatedTarget)) {
253
+ this.setState({
254
+ focusedTabId: undefined
255
+ });
208
256
  }
209
257
  };
210
258
  handleResize = () => {
@@ -213,67 +261,129 @@ let Tabs = (_dec = withDeterministicId(), _dec2 = withStyleNew(generateStyle), _
213
261
  });
214
262
  this._tabListPosition = getBoundingClientRect(this._tabList);
215
263
  };
216
- getNextTab(startIndex, step) {
217
- const tabs = Children.toArray(this.props.children).map(child => matchComponentTypes(child, [Panel]) && child);
264
+
265
+ /**
266
+ * The single source of truth for tab order, ids and state.
267
+ */
268
+ getTabs() {
269
+ const panels = [];
270
+ Children.toArray(this.props.children).forEach((child, index) => {
271
+ if (matchComponentTypes(child, [Panel])) {
272
+ panels.push({
273
+ index,
274
+ panel: child
275
+ });
276
+ }
277
+ });
278
+ const explicitlySelected = panels.find(({
279
+ panel
280
+ }) => panel.props.isSelected && !panel.props.isDisabled);
281
+ const fallbackIndex = explicitlySelected ? explicitlySelected.index : 0;
282
+ return panels.map(({
283
+ index,
284
+ panel
285
+ }) => {
286
+ const baseId = panel.props.id || this.props.deterministicId(`Tabs_${index}`);
287
+ const isDisabled = !!panel.props.isDisabled;
288
+ return {
289
+ index,
290
+ tabDomId: `tab-${baseId}`,
291
+ panelDomId: panel.props.id || `panel-${baseId}`,
292
+ isDisabled,
293
+ isSelected: !isDisabled && (!!panel.props.isSelected || index === fallbackIndex),
294
+ panel
295
+ };
296
+ });
297
+ }
298
+ getNextTab(tabs, startIndex, step) {
218
299
  const count = tabs.length;
219
- const change = step < 0 ? step + count : step;
220
- error(startIndex >= 0 && startIndex < count, `[Tabs] Invalid tab index: '${startIndex}'.`);
221
- let nextIndex = startIndex;
222
- let nextTab;
223
- do {
224
- nextIndex = (nextIndex + change) % count;
225
- nextTab = tabs[nextIndex];
226
- } while (nextTab && nextTab.props && nextTab.props.isDisabled);
227
- error(nextIndex >= 0 && nextIndex < count, `[Tabs] Invalid tab index: '${nextIndex}'.`);
228
- return {
229
- index: nextIndex,
230
- id: nextTab.props.id
231
- };
300
+ const from = tabs.findIndex(tab => tab.index === startIndex);
301
+ error(from >= 0, `[Tabs] Invalid tab index: '${startIndex}'.`);
302
+ if (from < 0) {
303
+ return undefined;
304
+ }
305
+ if (step === 0) {
306
+ return tabs[from];
307
+ }
308
+ for (let offset = 1; offset <= count; offset++) {
309
+ const candidate = tabs[((from + step * offset) % count + count) % count];
310
+ if (!candidate.isDisabled) {
311
+ return candidate;
312
+ }
313
+ }
314
+ return undefined;
315
+ }
316
+ getEdgeTab(tabs, edge) {
317
+ const enabled = tabs.filter(tab => !tab.isDisabled);
318
+ return edge === 'first' ? enabled[0] : enabled[enabled.length - 1];
319
+ }
320
+ getRovingTabId(tabs) {
321
+ const {
322
+ focusedTabId
323
+ } = this.state;
324
+ const focused = tabs.find(tab => tab.tabDomId === focusedTabId && !tab.isDisabled);
325
+ return focused?.tabDomId ?? tabs.find(tab => tab.isSelected)?.tabDomId ?? tabs.find(tab => !tab.isDisabled)?.tabDomId;
232
326
  }
233
327
  fireOnChange(event, {
234
328
  index,
235
- id
329
+ panel,
330
+ tabDomId
236
331
  }) {
237
332
  if (typeof this.props.onRequestTabChange === 'function') {
238
333
  this.props.onRequestTabChange(event, {
239
334
  index,
240
- id
335
+ id: panel.props.id
241
336
  });
242
337
  }
243
-
244
- // this is needed because keypress cancels scrolling. So we have to trigger the scrolling
338
+ this.scrollTabIntoView(tabDomId);
339
+ }
340
+ scrollTabIntoView(tabDomId) {
245
341
  // one "tick" later than the keypress
246
342
  setTimeout(() => {
247
343
  if (this.state.withTabListOverflow) {
248
- const tab = id ? this._tabList.querySelector(`#tab-${CSS.escape(id)}`) : null;
249
- this.showActiveTabIfOverlayed(tab);
344
+ this.showActiveTabIfOverlayed(this._tabList.querySelector(`#${CSS.escape(tabDomId)}`));
250
345
  }
251
346
  }, 0);
252
347
  }
253
- createTab(index, generatedId, selected, panel) {
254
- const id = panel.props.id || generatedId;
348
+ createTab(tab, isTabbable) {
349
+ const {
350
+ index,
351
+ tabDomId,
352
+ panelDomId,
353
+ isSelected,
354
+ isDisabled,
355
+ panel
356
+ } = tab;
255
357
  return _jsx(Tab, {
256
358
  variant: this.props.variant,
257
- id: `tab-${id}`,
258
- controls: panel.props.id || `panel-${id}`,
359
+ id: tabDomId,
360
+ controls: panelDomId,
259
361
  index: index,
260
- isSelected: selected,
261
- isDisabled: panel.props.isDisabled,
362
+ isSelected: isSelected,
363
+ isDisabled: isDisabled,
364
+ isTabbable: isTabbable,
262
365
  onClick: this.handleTabClick,
263
366
  onKeyDown: this.handleTabKeyDown,
367
+ onFocus: this.handleTabFocus,
264
368
  isOverflowScroll: this.props.tabOverflow === 'scroll',
265
369
  children: panel.props.renderTitle
266
370
  }, `tab-${index}`);
267
371
  }
268
- clonePanel(index, generatedId, selected, panel, activePanel) {
269
- const id = panel.props.id || generatedId;
372
+ clonePanel(tab, activePanel) {
373
+ const {
374
+ index,
375
+ tabDomId,
376
+ panelDomId,
377
+ isSelected,
378
+ panel
379
+ } = tab;
270
380
 
271
381
  // fixHeight can be 0, so simply `fixheight` could return falsy value
272
382
  const hasFixedHeight = typeof this.props.fixHeight !== 'undefined';
273
383
  const commonProps = {
274
- id: panel.props.id || `panel-${id}`,
275
- labelledBy: `tab-${id}`,
276
- isSelected: selected,
384
+ id: panelDomId,
385
+ labelledBy: tabDomId,
386
+ isSelected,
277
387
  variant: this.props.variant,
278
388
  maxHeight: !hasFixedHeight ? this.props.maxHeight : undefined,
279
389
  minHeight: !hasFixedHeight ? this.props.minHeight : '100%'
@@ -299,14 +409,19 @@ let Tabs = (_dec = withDeterministicId(), _dec2 = withStyleNew(generateStyle), _
299
409
  });
300
410
  }
301
411
  }
302
- handleFocusableRef = el => {
303
- this._focusable = el;
304
- };
305
412
  handleTabListRef = el => {
306
413
  this._tabList = el;
307
414
  };
308
415
  focus() {
309
- this._focusable && typeof this._focusable.focus === 'function' && this._focusable.focus();
416
+ this.focusTab(this.getRovingTabId(this.getTabs()));
417
+ }
418
+ focusTab(tabDomId) {
419
+ if (!tabDomId) {
420
+ return;
421
+ }
422
+ const tab = this._tabList?.querySelector(`#${CSS.escape(tabDomId)}`);
423
+ tab?.focus();
424
+ this.scrollTabIntoView(tabDomId);
310
425
  }
311
426
  handleScroll = event => {
312
427
  if (this.props.tabOverflow !== 'scroll' || !this.state.withTabListOverflow) {
@@ -334,27 +449,24 @@ let Tabs = (_dec = withDeterministicId(), _dec2 = withStyleNew(generateStyle), _
334
449
  onRequestTabChange,
335
450
  tabOverflow,
336
451
  styles,
452
+ dir,
337
453
  ...props
338
454
  } = this.props;
339
- const activePanels = Children.toArray(children).filter(child => matchComponentTypes(child, [Panel])).filter(child => child.props.active);
455
+ const tabDescriptors = this.getTabs();
456
+ const rovingTabId = this.getRovingTabId(tabDescriptors);
457
+ const tabsByIndex = new Map(tabDescriptors.map(tab => [tab.index, tab]));
458
+ const activePanels = tabDescriptors.map(tab => tab.panel).filter(panel => panel.props.active);
340
459
  if (activePanels.length > 1) {
341
460
  error(false, `[Tabs] Only one Panel can be marked as active.`);
342
461
  }
343
- const selectedChildIndex = Children.toArray(children).filter(child => matchComponentTypes(child, [Panel])).findIndex(child => child.props.isSelected && !child.props.isDisabled);
344
- const selectedIndex = selectedChildIndex >= 0 ? selectedChildIndex : 0;
345
- Children.toArray(children).map((child, index) => {
346
- if (matchComponentTypes(child, [Panel])) {
347
- const selected = !child.props.isDisabled && (child.props.isSelected || selectedIndex === index);
348
- const id = this.props.deterministicId(`Tabs_${index}`);
349
- tabs.push(this.createTab(index, id, selected, child));
350
- if (activePanels.length === 1) {
351
- panels.push(this.clonePanel(index, id, selected, child, activePanels[0]));
352
- } else {
353
- panels.push(this.clonePanel(index, id, selected, child));
354
- }
355
- } else {
462
+ Children.toArray(children).forEach((child, index) => {
463
+ const tab = tabsByIndex.get(index);
464
+ if (!tab) {
356
465
  panels.push(child);
466
+ return;
357
467
  }
468
+ tabs.push(this.createTab(tab, tab.tabDomId === rovingTabId));
469
+ panels.push(this.clonePanel(tab, activePanels.length === 1 ? activePanels[0] : undefined));
358
470
  });
359
471
  const withScrollFade = tabOverflow === 'scroll' && this.state.withTabListOverflow;
360
472
 
@@ -373,23 +485,21 @@ let Tabs = (_dec = withDeterministicId(), _dec2 = withStyleNew(generateStyle), _
373
485
  as: "div",
374
486
  css: styles?.container,
375
487
  "data-cid": "Tabs",
376
- children: [_jsx(Focusable, {
377
- ref: this.handleFocusableRef,
378
- children: () => _jsx(View, {
488
+ children: [_jsx(View, {
489
+ as: "div",
490
+ position: "relative",
491
+ borderRadius: "medium",
492
+ shouldAnimateFocus: false,
493
+ css: styles?.tabs,
494
+ children: _jsxs(View, {
379
495
  as: "div",
380
- position: "relative",
381
- borderRadius: "medium",
382
- shouldAnimateFocus: false,
383
- css: styles?.tabs,
384
- children: _jsxs(View, {
385
- as: "div",
386
- role: "tablist",
387
- css: styles?.tabList,
388
- "aria-label": screenReaderLabel,
389
- elementRef: this.handleTabListRef,
390
- onScroll: this.handleScroll,
391
- children: [tabs, withScrollFade && startScrollOverlay, withScrollFade && endScrollOverlay]
392
- })
496
+ role: "tablist",
497
+ css: styles?.tabList,
498
+ "aria-label": screenReaderLabel,
499
+ elementRef: this.handleTabListRef,
500
+ onScroll: this.handleScroll,
501
+ onBlur: this.handleTabListBlur,
502
+ children: [tabs, withScrollFade && startScrollOverlay, withScrollFade && endScrollOverlay]
393
503
  })
394
504
  }), _jsx("div", {
395
505
  css: styles?.panelsContainer,
@@ -397,6 +507,6 @@ let Tabs = (_dec = withDeterministicId(), _dec2 = withStyleNew(generateStyle), _
397
507
  })]
398
508
  });
399
509
  }
400
- }) || _class) || _class);
510
+ }) || _class) || _class) || _class);
401
511
  export default Tabs;
402
512
  export { Tabs, Panel };
@@ -22,5 +22,5 @@
22
22
  * SOFTWARE.
23
23
  */
24
24
 
25
- const allowedProps = ['children', 'variant', 'screenReaderLabel', 'onRequestTabChange', 'maxWidth', 'maxHeight', 'minHeight', 'fixHeight', 'margin', 'padding', 'textAlign', 'elementRef', 'tabOverflow', 'shouldFocusOnRender'];
25
+ const allowedProps = ['children', 'variant', 'screenReaderLabel', 'onRequestTabChange', 'maxWidth', 'maxHeight', 'minHeight', 'fixHeight', 'margin', 'padding', 'textAlign', 'elementRef', 'tabOverflow', 'shouldFocusOnRender', 'activationMode'];
26
26
  export { allowedProps };
@@ -91,12 +91,30 @@ let Tab = exports.Tab = (_dec = (0, _emotion.withStyleNew)(_styles.default), _de
91
91
  });
92
92
  }
93
93
  };
94
+ handleFocus = event => {
95
+ const {
96
+ onFocus,
97
+ index,
98
+ id,
99
+ isDisabled
100
+ } = this.props;
101
+ if (isDisabled) {
102
+ return;
103
+ }
104
+ if (typeof onFocus === 'function') {
105
+ onFocus(event, {
106
+ index,
107
+ id
108
+ });
109
+ }
110
+ };
94
111
  render() {
95
112
  const {
96
113
  id,
97
114
  variant,
98
115
  isSelected,
99
116
  isDisabled,
117
+ isTabbable = isSelected,
100
118
  controls,
101
119
  children,
102
120
  styles,
@@ -109,11 +127,12 @@ let Tab = exports.Tab = (_dec = (0, _emotion.withStyleNew)(_styles.default), _de
109
127
  id: id,
110
128
  onClick: this.handleClick,
111
129
  onKeyDown: this.handleKeyDown,
130
+ onFocus: this.handleFocus,
112
131
  css: styles?.tab,
113
- "aria-selected": isSelected ? 'true' : undefined,
132
+ "aria-selected": isSelected ? 'true' : 'false',
114
133
  "aria-disabled": isDisabled ? 'true' : undefined,
115
134
  "aria-controls": controls,
116
- tabIndex: isSelected && !isDisabled ? 0 : undefined,
135
+ tabIndex: isDisabled ? undefined : isTabbable ? 0 : -1,
117
136
  position: "relative",
118
137
  focusPosition: "offset",
119
138
  children: (0, _uiReactUtils.callRenderProp)(children)
@@ -28,4 +28,4 @@ exports.allowedProps = void 0;
28
28
  * SOFTWARE.
29
29
  */
30
30
 
31
- const allowedProps = exports.allowedProps = ['variant', 'id', 'index', 'controls', 'isDisabled', 'isSelected', 'onClick', 'onKeyDown', 'children', 'isOverflowScroll'];
31
+ const allowedProps = exports.allowedProps = ['variant', 'id', 'index', 'controls', 'isDisabled', 'isSelected', 'isTabbable', 'onClick', 'onKeyDown', 'onFocus', 'children', 'isOverflowScroll'];