vira 31.25.0 → 31.26.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.
@@ -3,13 +3,15 @@ import { arrayToObject, filterMap, getObjectTypedKeys, } from '@augment-vir/comm
3
3
  import { ContrastLevelName } from '@electrovir/color/dist/data/contrast/contrast.js';
4
4
  import { classMap, css, defineElementEvent, html, listen, nothing, onDomCreated, unsafeCSS, } from 'element-vir';
5
5
  import { routeHasPaths, } from 'spa-router-vir';
6
+ import { themeDefaultKey } from 'theme-vir/dist/color-theme/color-theme.js';
7
+ import { Check24Icon } from '../icons/icon-svgs/24/check-24.icon.js';
6
8
  import { createFocusStyles } from '../styles/focus.js';
9
+ import { viraFontCssVars } from '../styles/font.js';
7
10
  import { viraFormCssVars } from '../styles/form-styles.js';
8
11
  import { standaloneThemeColorNames, ViraColorVariant, viraColorVariantToHostClassKey, } from '../styles/form-variants.js';
9
12
  import { noNativeFormStyles, noUserSelect, viraDisabledStyles, viraTheme } from '../styles/index.js';
10
13
  import { viraThemeByKeys, ViraThemeColorName } from '../styles/vira-color-theme-object.js';
11
14
  import { defineViraElement } from '../util/define-vira-element.js';
12
- import { createOverflowObserver } from '../util/overflow-observer.js';
13
15
  import { renderMenuItemEntries } from '../util/pop-up-helpers.js';
14
16
  import { ViraMenuTrigger } from './pop-up/vira-menu-trigger.element.js';
15
17
  import { ViraMenuCornerStyle } from './pop-up/vira-menu.element.js';
@@ -41,6 +43,69 @@ export var ViraTabsIconLayout;
41
43
  /** Icon renders beside the label. */
42
44
  ViraTabsIconLayout["Horizontal"] = "horizontal";
43
45
  })(ViraTabsIconLayout || (ViraTabsIconLayout = {}));
46
+ function measureElementWidth(mirrorElement, selector) {
47
+ const element = mirrorElement.querySelector(selector);
48
+ return element ? element.getBoundingClientRect().width : 0;
49
+ }
50
+ /**
51
+ * Groups a flat list of tabs into clusters. Consecutive tabs sharing the same `group` string are
52
+ * merged into a single cluster; ungrouped tabs (and tabs whose `group` differs from their
53
+ * predecessor) each start a new cluster.
54
+ *
55
+ * @category Internal
56
+ */
57
+ export function buildClusters(tabList) {
58
+ return tabList.reduce((accumulatedClusters, tab) => {
59
+ const lastCluster = accumulatedClusters[accumulatedClusters.length - 1];
60
+ if (tab.group != undefined && lastCluster && lastCluster.group === tab.group) {
61
+ return [
62
+ ...accumulatedClusters.slice(0, -1),
63
+ {
64
+ group: lastCluster.group,
65
+ tabs: [
66
+ ...lastCluster.tabs,
67
+ tab,
68
+ ],
69
+ },
70
+ ];
71
+ }
72
+ return [
73
+ ...accumulatedClusters,
74
+ {
75
+ group: tab.group,
76
+ tabs: [tab],
77
+ },
78
+ ];
79
+ }, []);
80
+ }
81
+ function renderGroupedContent(tabList, renderItem) {
82
+ const clusters = buildClusters(tabList);
83
+ return clusters.map((cluster, clusterIndex) => {
84
+ const previousCluster = clusters[clusterIndex - 1];
85
+ const dividerTemplate = previousCluster && previousCluster.group != undefined && cluster.group != undefined
86
+ ? html `
87
+ <span class="tab-cluster-divider"></span>
88
+ `
89
+ : nothing;
90
+ const labelTemplate = cluster.group == undefined
91
+ ? nothing
92
+ : html `
93
+ <span class="tab-group-label">${cluster.group}</span>
94
+ `;
95
+ return html `
96
+ ${dividerTemplate}
97
+ <div
98
+ class=${classMap({
99
+ 'tab-cluster': true,
100
+ standalone: cluster.group == undefined,
101
+ })}
102
+ >
103
+ ${labelTemplate}
104
+ <div class="tab-cluster-tabs">${cluster.tabs.map(renderItem)}</div>
105
+ </div>
106
+ `;
107
+ });
108
+ }
44
109
  /**
45
110
  * A tab bar element that renders an array of tabs with an animated selection indicator.
46
111
  *
@@ -54,7 +119,8 @@ export const ViraTabs = defineViraElement()({
54
119
  },
55
120
  state() {
56
121
  return {
57
- isOverflowing: false,
122
+ /** How many of the visible tabs are collapsed into the overflow "more" menu. */
123
+ overflowCount: 0,
58
124
  /** A callback to remove all internal observers. */
59
125
  cleanupObserver: undefined,
60
126
  };
@@ -89,15 +155,14 @@ export const ViraTabs = defineViraElement()({
89
155
  }),
90
156
  'vira-tabs-icon-layout-vertical': ({ inputs }) => !inputs.iconLayout || inputs.iconLayout === ViraTabsIconLayout.Vertical,
91
157
  'vira-tabs-icon-layout-horizontal': ({ inputs }) => inputs.iconLayout === ViraTabsIconLayout.Horizontal,
92
- 'vira-tabs-overflowing': ({ state }) => state.isOverflowing,
93
158
  'vira-tabs-fill-width': ({ inputs }) => !!inputs.shouldFillWidth,
94
159
  },
95
160
  cssVars: {
96
161
  'vira-tabs-active-color': viraThemeByKeys[viraColorVariantToHostClassKey[ViraColorVariant.Info]]['behind-bg'][ContrastLevelName.NonBodyText].background.value,
97
162
  'vira-tabs-active-hover-color': viraThemeByKeys[viraColorVariantToHostClassKey[ViraColorVariant.Info]]['behind-bg'][ContrastLevelName.Header].background.value,
98
- 'vira-tabs-inactive-color': viraTheme.colors['vira-grey-foreground-header'].foreground.value,
99
- 'vira-tabs-inactive-hover-color': viraTheme.colors['vira-grey-foreground-non-body'].foreground.value,
100
- 'vira-tabs-bar-thickness': '3px',
163
+ 'vira-tabs-inactive-color': viraTheme.colors[themeDefaultKey].foreground.value,
164
+ 'vira-tabs-inactive-hover-color': viraTheme.colors[themeDefaultKey].foreground.value,
165
+ 'vira-tabs-bar-thickness': '2px',
101
166
  },
102
167
  styles: ({ hostClasses, cssVars }) => {
103
168
  function buildThemedTabsColors(colorName) {
@@ -146,17 +211,26 @@ export const ViraTabs = defineViraElement()({
146
211
  box-sizing: border-box;
147
212
  ${noUserSelect};
148
213
  width: 100%;
214
+ /** Tab labels, the overflow trigger, and menu items never wrap to a second line. */
215
+ white-space: nowrap;
149
216
  }
150
217
 
151
218
  .tabs-container {
152
219
  display: flex;
153
220
  position: relative;
154
- list-style: none;
155
- overflow: hidden;
156
221
  margin: 0;
157
222
  padding: 0;
158
223
  }
159
224
 
225
+ .tabs-measure {
226
+ position: absolute;
227
+ top: 0;
228
+ left: 0;
229
+ overflow: visible;
230
+ visibility: hidden;
231
+ pointer-events: none;
232
+ }
233
+
160
234
  ${hostClasses['vira-tabs-bar-top'].selector},
161
235
  ${hostClasses['vira-tabs-bar-bottom'].selector} {
162
236
  & .tabs-container {
@@ -171,7 +245,7 @@ export const ViraTabs = defineViraElement()({
171
245
  }
172
246
  }
173
247
 
174
- li {
248
+ .tab-item {
175
249
  ${noNativeFormStyles};
176
250
  cursor: pointer;
177
251
  display: flex;
@@ -180,7 +254,8 @@ export const ViraTabs = defineViraElement()({
180
254
  text-align: center;
181
255
  position: relative;
182
256
  color: ${cssVars['vira-tabs-inactive-color'].value};
183
- font-size: ${viraFormCssVars['vira-form-medium-text-size'].value};
257
+ font-size: ${viraFormCssVars['vira-form-small-text-size'].value};
258
+ font-weight: ${viraFontCssVars['vira-font-weight-medium'].value};
184
259
  text-decoration: none;
185
260
  ${createFocusStyles({
186
261
  renderInside: true,
@@ -217,13 +292,12 @@ export const ViraTabs = defineViraElement()({
217
292
  border-bottom: 1px solid
218
293
  ${viraTheme.colors['vira-grey-foreground-decoration'].foreground.value};
219
294
 
220
- & li::after {
295
+ & .tab-item::after {
221
296
  bottom: 0;
222
297
  left: 0;
223
298
  right: 0;
224
299
  height: ${cssVars['vira-tabs-bar-thickness'].value};
225
- border-radius: ${cssVars['vira-tabs-bar-thickness'].value}
226
- ${cssVars['vira-tabs-bar-thickness'].value} 0 0;
300
+ border-radius: 0;
227
301
  }
228
302
  }
229
303
 
@@ -231,13 +305,12 @@ export const ViraTabs = defineViraElement()({
231
305
  border-top: 1px solid
232
306
  ${viraTheme.colors['vira-grey-foreground-decoration'].foreground.value};
233
307
 
234
- & li::after {
308
+ & .tab-item::after {
235
309
  top: 0;
236
310
  left: 0;
237
311
  right: 0;
238
312
  height: ${cssVars['vira-tabs-bar-thickness'].value};
239
- border-radius: 0 0 ${cssVars['vira-tabs-bar-thickness'].value}
240
- ${cssVars['vira-tabs-bar-thickness'].value};
313
+ border-radius: 0;
241
314
  }
242
315
  }
243
316
 
@@ -245,13 +318,12 @@ export const ViraTabs = defineViraElement()({
245
318
  border-left: 1px solid
246
319
  ${viraTheme.colors['vira-grey-foreground-decoration'].foreground.value};
247
320
 
248
- & li::after {
321
+ & .tab-item::after {
249
322
  top: 0;
250
323
  bottom: 0;
251
324
  left: 0;
252
325
  width: ${cssVars['vira-tabs-bar-thickness'].value};
253
- border-radius: 0 ${cssVars['vira-tabs-bar-thickness'].value}
254
- ${cssVars['vira-tabs-bar-thickness'].value} 0;
326
+ border-radius: 0;
255
327
  }
256
328
  }
257
329
 
@@ -259,13 +331,12 @@ export const ViraTabs = defineViraElement()({
259
331
  border-right: 1px solid
260
332
  ${viraTheme.colors['vira-grey-foreground-decoration'].foreground.value};
261
333
 
262
- & li::after {
334
+ & .tab-item::after {
263
335
  top: 0;
264
336
  bottom: 0;
265
337
  right: 0;
266
338
  width: ${cssVars['vira-tabs-bar-thickness'].value};
267
- border-radius: ${cssVars['vira-tabs-bar-thickness'].value} 0 0
268
- ${cssVars['vira-tabs-bar-thickness'].value};
339
+ border-radius: 0;
269
340
  }
270
341
  }
271
342
 
@@ -291,32 +362,16 @@ export const ViraTabs = defineViraElement()({
291
362
  }
292
363
  }
293
364
 
294
- ${hostClasses['vira-tabs-overflowing'].selector} .tabs-container {
295
- visibility: hidden;
296
- height: 0;
297
- }
298
-
299
- .overflow-menu {
300
- display: none;
301
- }
302
-
303
- ${hostClasses['vira-tabs-overflowing'].selector} .overflow-menu {
304
- display: flex;
305
- align-items: center;
306
- width: fit-content;
307
- padding-left: 8px;
308
- }
309
-
310
365
  ${hostClasses['vira-tabs-fill-width'].selector} {
311
366
  & .tabs-container {
312
367
  flex-grow: 1;
313
368
  }
314
369
 
315
- & li {
370
+ & .tab-item {
316
371
  flex-grow: 1;
317
372
  }
318
373
 
319
- & .tabs-container ${ViraLink} {
374
+ & .tab-item ${ViraLink} {
320
375
  flex-grow: 1;
321
376
  justify-content: center;
322
377
  }
@@ -326,18 +381,50 @@ export const ViraTabs = defineViraElement()({
326
381
  text-decoration: none;
327
382
  }
328
383
 
329
- .tabs-container ${ViraLink} {
384
+ .tab-item ${ViraLink} {
330
385
  display: flex;
331
386
  padding: 8px 16px;
332
387
  }
333
388
 
334
- ${ViraMenuTrigger} {
335
- margin: 3px 0;
389
+ .tab-more {
390
+ display: flex;
391
+ flex-shrink: 0;
392
+ align-items: center;
393
+
394
+ & ${ViraButton} {
395
+ flex-shrink: 0;
396
+ }
397
+ }
398
+
399
+ .tabs-container.grouped {
400
+ align-items: flex-end;
401
+ }
402
+
403
+ .tab-cluster {
404
+ display: flex;
405
+ flex-direction: column;
406
+ justify-content: flex-end;
407
+ }
408
+
409
+ .tab-cluster-tabs {
410
+ display: flex;
411
+ flex-direction: row;
412
+ }
413
+
414
+ .tab-group-label {
415
+ font-size: 11px;
416
+ font-weight: ${viraFontCssVars['vira-font-weight-medium'].value};
417
+ padding: 8px 16px 4px;
418
+ color: ${viraTheme.colors['vira-grey-foreground-header'].foreground.value};
336
419
  }
337
420
 
338
- .overflow-menu ${ViraButton} {
421
+ .tab-cluster-divider {
339
422
  flex-shrink: 0;
340
- white-space: nowrap;
423
+ align-self: stretch;
424
+ width: 1px;
425
+ margin: 8px 0;
426
+ background-color: ${viraTheme.colors['vira-grey-foreground-decoration'].foreground
427
+ .value};
341
428
  }
342
429
  `;
343
430
  },
@@ -345,85 +432,22 @@ export const ViraTabs = defineViraElement()({
345
432
  state.cleanupObserver?.();
346
433
  },
347
434
  render({ inputs, state, updateState, host, dispatch, events }) {
348
- const tabs = filterMap(inputs.tabs, (tab) => {
349
- if (tab.isHidden) {
350
- return undefined;
351
- }
435
+ function renderTabInner(tab, forMeasurement) {
352
436
  const isSelected = routeHasPaths(inputs.currentRoute, tab.paths, {
353
437
  exactMatch: tab.exactMatch,
354
438
  });
355
439
  const iconTemplate = tab.icon
356
440
  ? html `
357
- <${ViraIcon.assign({
441
+ <${ViraIcon.assign({
358
442
  icon: tab.icon,
359
443
  })}></${ViraIcon}>
360
- `
444
+ `
361
445
  : nothing;
362
446
  const isInert = isSelected || !!tab.isDisabled;
363
- return html `
364
- <li
365
- class=${classMap({
366
- selected: isSelected,
367
- disabled: !!tab.isDisabled,
368
- })}
369
- role="presentation"
370
- ${listen('click', () => {
371
- if (!tab.isDisabled) {
372
- dispatch(new events.tabSelect(tab));
373
- }
374
- })}
375
- >
376
- <${ViraLink.assign({
377
- route: {
378
- router: inputs.router,
379
- route: {
380
- paths: tab.paths.fullPaths,
381
- },
382
- scrollToTop: true,
383
- },
384
- disableLinkStyles: true,
385
- attributePassthrough: {
386
- a: {
387
- role: 'tab',
388
- 'aria-selected': String(isSelected),
389
- 'aria-disabled': String(!!tab.isDisabled),
390
- tabindex: isInert ? '-1' : undefined,
391
- },
392
- },
393
- })}>
394
- <span class="tab-content">
395
- ${iconTemplate}
396
- <${ViraBoldText.assign({
397
- text: tab.label,
398
- bold: isSelected,
399
- })}
400
- class="tab-label"
401
- ></${ViraBoldText}>
402
- </span>
403
- </${ViraLink}>
404
- </li>
405
- `;
406
- }, check.isTruthy);
407
- const selectedTab = inputs.tabs.find((tab) => routeHasPaths(inputs.currentRoute, tab.paths, {
408
- exactMatch: tab.exactMatch,
409
- }));
410
- const menuItems = renderMenuItemEntries(filterMap(inputs.tabs, (tab) => {
411
- if (tab.isHidden) {
412
- return undefined;
413
- }
414
- const isSelected = routeHasPaths(inputs.currentRoute, tab.paths, {
415
- exactMatch: tab.exactMatch,
416
- });
417
- const iconTemplate = tab.icon
418
- ? html `
419
- <${ViraIcon.assign({
420
- icon: tab.icon,
421
- })}></${ViraIcon}>
422
- `
423
- : nothing;
424
447
  return {
448
+ isSelected,
425
449
  content: html `
426
- <${ViraLink.assign({
450
+ <${ViraLink.assign({
427
451
  route: {
428
452
  router: inputs.router,
429
453
  route: {
@@ -432,65 +456,342 @@ export const ViraTabs = defineViraElement()({
432
456
  scrollToTop: true,
433
457
  },
434
458
  disableLinkStyles: true,
435
- stylePassthrough: {
436
- a: css `
437
- display: flex;
438
- align-items: center;
439
- gap: 8px;
440
- `,
441
- },
459
+ attributePassthrough: forMeasurement
460
+ ? undefined
461
+ : {
462
+ a: {
463
+ role: 'tab',
464
+ 'aria-selected': String(isSelected),
465
+ 'aria-disabled': String(!!tab.isDisabled),
466
+ tabindex: isInert ? '-1' : undefined,
467
+ },
468
+ },
442
469
  })}>
443
- ${iconTemplate} ${tab.label}
444
- </${ViraLink}>
445
- `,
470
+ <span class="tab-content">
471
+ ${iconTemplate}
472
+ <${ViraBoldText.assign({
473
+ text: tab.label,
474
+ bold: isSelected,
475
+ })}
476
+ class="tab-label"
477
+ ></${ViraBoldText}>
478
+ </span>
479
+ </${ViraLink}>
480
+ `,
481
+ };
482
+ }
483
+ function handleTabClick(tab) {
484
+ if (!tab.isDisabled) {
485
+ dispatch(new events.tabSelect(tab));
486
+ }
487
+ }
488
+ function renderTabItem(tab) {
489
+ const { isSelected, content } = renderTabInner(tab, false);
490
+ return html `
491
+ <div
492
+ class=${classMap({
493
+ 'tab-item': true,
446
494
  selected: isSelected,
447
- disabled: tab.isDisabled,
448
- onClick() {
449
- if (!tab.isDisabled) {
450
- dispatch(new events.tabSelect(tab));
495
+ disabled: !!tab.isDisabled,
496
+ })}
497
+ role="presentation"
498
+ ${listen('click', () => handleTabClick(tab))}
499
+ >
500
+ ${content}
501
+ </div>
502
+ `;
503
+ }
504
+ function renderMeasureTabItem(tab) {
505
+ const { isSelected, content } = renderTabInner(tab, true);
506
+ return html `
507
+ <div
508
+ class=${classMap({
509
+ 'tab-item': true,
510
+ 'measure-selected': isSelected,
511
+ })}
512
+ >
513
+ ${content}
514
+ </div>
515
+ `;
516
+ }
517
+ function buildMenuItems(tabList) {
518
+ return renderMenuItemEntries(filterMap(tabList, (tab) => {
519
+ const isSelected = routeHasPaths(inputs.currentRoute, tab.paths, {
520
+ exactMatch: tab.exactMatch,
521
+ });
522
+ const iconTemplate = tab.icon
523
+ ? html `
524
+ <${ViraIcon.assign({
525
+ icon: tab.icon,
526
+ })}></${ViraIcon}>
527
+ `
528
+ : nothing;
529
+ return {
530
+ content: html `
531
+ <${ViraLink.assign({
532
+ route: {
533
+ router: inputs.router,
534
+ route: {
535
+ paths: tab.paths.fullPaths,
536
+ },
537
+ scrollToTop: true,
538
+ },
539
+ disableLinkStyles: true,
540
+ stylePassthrough: {
541
+ a: css `
542
+ display: flex;
543
+ align-items: center;
544
+ gap: 8px;
545
+ `,
546
+ },
547
+ })}>
548
+ ${iconTemplate} ${tab.label}
549
+ </${ViraLink}>
550
+ `,
551
+ selected: isSelected,
552
+ disabled: tab.isDisabled,
553
+ onClick() {
554
+ handleTabClick(tab);
555
+ },
556
+ };
557
+ }, check.isTruthy));
558
+ }
559
+ function measureOverflow(mirrorElement) {
560
+ const availableWidth = host.clientWidth;
561
+ if (!availableWidth) {
562
+ return;
563
+ }
564
+ /**
565
+ * Vertical (left/right) tab bars stack their tabs in a column, so horizontal
566
+ * width-based overflow does not apply: keep every tab inline.
567
+ */
568
+ const isVertical = globalThis
569
+ .getComputedStyle(mirrorElement)
570
+ .flexDirection.startsWith('column');
571
+ if (isVertical) {
572
+ if (state.overflowCount !== 0) {
573
+ updateState({
574
+ overflowCount: 0,
575
+ });
576
+ }
577
+ return;
578
+ }
579
+ const tabElements = Array.from(mirrorElement.querySelectorAll('.tab-item'));
580
+ const tabCount = tabElements.length;
581
+ if (!tabCount) {
582
+ return;
583
+ }
584
+ const tabWidths = tabElements.map((element) => element.getBoundingClientRect().width);
585
+ /**
586
+ * In grouped mode a partially-shown cluster still renders its group label and its
587
+ * leading divider, so its inline width is `max(labelWidth, shownTabsWidth)` rather than
588
+ * a bare sum of tab widths. Capture each tab's cluster geometry (in tab order) so the
589
+ * inline width for any leading count can be derived from what is actually rendered.
590
+ */
591
+ const clusterElements = Array.from(mirrorElement.querySelectorAll('.tab-cluster'));
592
+ const containerLeft = mirrorElement.getBoundingClientRect().left;
593
+ const tabClusterInfo = clusterElements.flatMap((clusterElement) => {
594
+ const clusterLeft = clusterElement.getBoundingClientRect().left - containerLeft;
595
+ const labelElement = clusterElement.querySelector('.tab-group-label');
596
+ const clusterLabelWidth = labelElement
597
+ ? labelElement.getBoundingClientRect().width
598
+ : 0;
599
+ return Array.from(clusterElement.querySelectorAll('.tab-item')).map((tabElement, indexInCluster) => {
600
+ return {
601
+ clusterLeft,
602
+ clusterLabelWidth,
603
+ indexInCluster,
604
+ width: tabElement.getBoundingClientRect().width,
605
+ };
606
+ });
607
+ });
608
+ /** Width of the inline area when the first `count` tabs are shown. */
609
+ function inlineWidthForCount(count) {
610
+ if (count <= 0) {
611
+ return 0;
612
+ }
613
+ else if (!clusterElements.length) {
614
+ return tabWidths.slice(0, count).reduce((sum, width) => sum + width, 0);
615
+ }
616
+ const lastInfo = tabClusterInfo[count - 1];
617
+ if (!lastInfo) {
618
+ return 0;
619
+ }
620
+ const inlineTabsInLastCluster = tabClusterInfo
621
+ .slice(count - 1 - lastInfo.indexInCluster, count)
622
+ .reduce((sum, info) => sum + info.width, 0);
623
+ return (lastInfo.clusterLeft +
624
+ Math.max(lastInfo.clusterLabelWidth, inlineTabsInLastCluster));
625
+ }
626
+ const totalWidth = inlineWidthForCount(tabCount);
627
+ /**
628
+ * Largest number of leading tabs whose inline width fits within the available width
629
+ * minus the given reserve (the width the "more" button will occupy). Can return 0.
630
+ */
631
+ function maxInlineForReserve(reservePx) {
632
+ const limit = availableWidth - reservePx;
633
+ return tabElements.reduce((fittingCount, _tabElement, index) => inlineWidthForCount(index + 1) <= limit ? index + 1 : fittingCount, 0);
634
+ }
635
+ /** When every tab fits on its own, there is no overflow and no "more" button. */
636
+ if (totalWidth <= availableWidth) {
637
+ if (state.overflowCount !== 0) {
638
+ updateState({
639
+ overflowCount: 0,
640
+ });
641
+ }
642
+ return;
643
+ }
644
+ const defaultButtonWidth = measureElementWidth(mirrorElement, '.measure-more-default');
645
+ const labeledButtonWidth = measureElementWidth(mirrorElement, '.measure-more-labeled');
646
+ const selectedElement = mirrorElement.querySelector('.tab-item.measure-selected');
647
+ const selectedIndex = selectedElement ? tabElements.indexOf(selectedElement) : -1;
648
+ /**
649
+ * Reserving the default ("more") trigger reveals whether the selected tab would end up
650
+ * collapsed.
651
+ */
652
+ const defaultInlineCount = maxInlineForReserve(defaultButtonWidth);
653
+ const selectedIsCollapsed = selectedIndex >= 0 && selectedIndex >= defaultInlineCount;
654
+ /**
655
+ * When the selected tab is collapsed, the trigger always shows that tab's own label
656
+ * (with a checkmark), reserving its width even when it cannot fully fit (it is allowed
657
+ * to overflow rather than hide which tab is selected). Otherwise the trigger shows the
658
+ * plain default overflow label.
659
+ */
660
+ const inlineCount = selectedIsCollapsed
661
+ ? maxInlineForReserve(labeledButtonWidth)
662
+ : defaultInlineCount;
663
+ const newOverflowCount = Math.max(1, tabCount - inlineCount);
664
+ if (newOverflowCount !== state.overflowCount) {
665
+ updateState({
666
+ overflowCount: newOverflowCount,
667
+ });
668
+ }
669
+ }
670
+ function setUpOverflowMeasurement(mirrorElement) {
671
+ state.cleanupObserver?.();
672
+ let frameId = undefined;
673
+ function scheduleMeasure() {
674
+ if (frameId != undefined) {
675
+ return;
676
+ }
677
+ frameId = requestAnimationFrame(() => {
678
+ frameId = undefined;
679
+ measureOverflow(mirrorElement);
680
+ });
681
+ }
682
+ const resizeObserver = new ResizeObserver(scheduleMeasure);
683
+ resizeObserver.observe(host);
684
+ /**
685
+ * Observing the (out-of-flow) measurement mirror catches late layout of its async
686
+ * `ViraButton` children, whose real widths drive the overflow math.
687
+ */
688
+ resizeObserver.observe(mirrorElement);
689
+ const mutationObserver = new MutationObserver(scheduleMeasure);
690
+ mutationObserver.observe(mirrorElement, {
691
+ childList: true,
692
+ subtree: true,
693
+ characterData: true,
694
+ });
695
+ scheduleMeasure();
696
+ updateState({
697
+ cleanupObserver() {
698
+ if (frameId != undefined) {
699
+ cancelAnimationFrame(frameId);
451
700
  }
701
+ resizeObserver.disconnect();
702
+ mutationObserver.disconnect();
452
703
  },
453
- };
454
- }, check.isTruthy));
704
+ });
705
+ }
706
+ const visibleTabs = inputs.tabs.filter((tab) => !tab.isHidden);
707
+ const hasGroups = visibleTabs.some((tab) => tab.group != undefined);
708
+ const overflowCount = Math.min(visibleTabs.length, Math.max(0, state.overflowCount));
709
+ const inlineCount = visibleTabs.length - overflowCount;
710
+ const inlineTabs = visibleTabs.slice(0, inlineCount);
711
+ const overflowTabs = visibleTabs.slice(inlineCount);
712
+ const selectedIndex = visibleTabs.findIndex((tab) => routeHasPaths(inputs.currentRoute, tab.paths, {
713
+ exactMatch: tab.exactMatch,
714
+ }));
715
+ const selectedTab = selectedIndex >= 0 ? visibleTabs[selectedIndex] : undefined;
716
+ /** Whether the selected tab is currently collapsed into the overflow menu. */
717
+ const selectedIsCollapsed = selectedIndex >= 0 && selectedIndex >= inlineCount;
718
+ const inlineContent = hasGroups
719
+ ? renderGroupedContent(inlineTabs, renderTabItem)
720
+ : inlineTabs.map(renderTabItem);
721
+ const measureContent = hasGroups
722
+ ? renderGroupedContent(visibleTabs, renderMeasureTabItem)
723
+ : visibleTabs.map(renderMeasureTabItem);
724
+ const overflowLabel = inputs.overflowLabel || 'More';
725
+ /**
726
+ * Hidden copies of the "more" button in each of its possible shapes, rendered inside the
727
+ * measurement mirror so their true widths can be measured before choosing which one fits:
728
+ * the default overflow label, and the collapsed selected tab's own label (with a
729
+ * checkmark).
730
+ */
731
+ const measureMoreButtons = html `
732
+ <${ViraButton.assign({
733
+ text: overflowLabel,
734
+ showMenuCaret: true,
735
+ color: ViraColorVariant.Neutral,
736
+ })}
737
+ class="measure-more-default"
738
+ ></${ViraButton}>
739
+ ${selectedTab
740
+ ? html `
741
+ <${ViraButton.assign({
742
+ text: selectedTab.label,
743
+ icon: Check24Icon,
744
+ showMenuCaret: true,
745
+ color: ViraColorVariant.Neutral,
746
+ })}
747
+ class="measure-more-labeled"
748
+ ></${ViraButton}>
749
+ `
750
+ : nothing}
751
+ `;
752
+ const overflowMenuTemplate = overflowTabs.length
753
+ ? html `
754
+ <div class="tab-more">
755
+ <${ViraMenuTrigger.assign({
756
+ horizontalAnchor: inputs.menuHorizontalAnchor,
757
+ isDisabled: inputs.menuIsDisabled,
758
+ popUpOffset: inputs.menuPopUpOffset,
759
+ menuCornerStyle: ViraMenuCornerStyle.AllRounded,
760
+ })}>
761
+ <${ViraButton.assign({
762
+ text: selectedIsCollapsed ? selectedTab?.label : overflowLabel,
763
+ icon: selectedIsCollapsed ? Check24Icon : undefined,
764
+ showMenuCaret: true,
765
+ color: ViraColorVariant.Neutral,
766
+ })}
767
+ slot=${ViraMenuTrigger.slotNames['vira-menu-trigger-trigger']}
768
+ ></${ViraButton}>
769
+ ${buildMenuItems(overflowTabs)}
770
+ </${ViraMenuTrigger}>
771
+ </div>
772
+ `
773
+ : nothing;
455
774
  return html `
456
- <${ViraMenuTrigger.assign({
457
- horizontalAnchor: inputs.menuHorizontalAnchor,
458
- isDisabled: inputs.menuIsDisabled,
459
- popUpOffset: inputs.menuPopUpOffset,
460
- menuCornerStyle: ViraMenuCornerStyle.AllRounded,
775
+ <div
776
+ class=${classMap({
777
+ 'tabs-container': true,
778
+ 'tabs-measure': true,
779
+ grouped: hasGroups,
461
780
  })}
462
- class="overflow-menu"
781
+ aria-hidden="true"
782
+ ${onDomCreated(setUpOverflowMeasurement)}
463
783
  >
464
- <${ViraButton.assign({
465
- text: selectedTab?.label || '',
466
- showMenuCaret: true,
467
- color: ViraColorVariant.Neutral,
784
+ ${measureContent} ${measureMoreButtons}
785
+ </div>
786
+ <div
787
+ class=${classMap({
788
+ 'tabs-container': true,
789
+ grouped: hasGroups,
468
790
  })}
469
- slot=${ViraMenuTrigger.slotNames['vira-menu-trigger-trigger']}
470
- ></${ViraButton}>
471
- ${menuItems}
472
- </${ViraMenuTrigger}>
473
- <ul
474
- class="tabs-container"
475
791
  role="tablist"
476
- ${onDomCreated((tabsElement) => {
477
- state.cleanupObserver?.();
478
- updateState({
479
- cleanupObserver: createOverflowObserver({
480
- element: tabsElement,
481
- widthElement: host,
482
- hysteresisPx: 16,
483
- onChange(isOverflowing) {
484
- updateState({
485
- isOverflowing,
486
- });
487
- },
488
- }),
489
- });
490
- })}
491
792
  >
492
- ${tabs}
493
- </ul>
793
+ ${inlineContent} ${overflowMenuTemplate}
794
+ </div>
494
795
  `;
495
796
  },
496
797
  });