• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IgniteUI / igniteui-webcomponents / 13265423658

11 Feb 2025 02:39PM UTC coverage: 98.224% (-0.04%) from 98.265%
13265423658

Pull #1352

github

web-flow
Merge 107e76eb5 into 44d06629e
Pull Request #1352: Refactor Tab component

3847 of 4049 branches covered (95.01%)

Branch coverage included in aggregate %.

420 of 433 new or added lines in 4 files covered. (97.0%)

2 existing lines in 1 file now uncovered.

24857 of 25174 relevant lines covered (98.74%)

468.4 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.97
/src/components/tabs/tabs.ts
1
import { LitElement, html, nothing } from 'lit';
10✔
2
import {
10✔
3
  eventOptions,
10✔
4
  property,
10✔
5
  query,
10✔
6
  queryAssignedElements,
10✔
7
  state,
10✔
8
} from 'lit/decorators.js';
10✔
9
import { type Ref, createRef, ref } from 'lit/directives/ref.js';
10✔
10

10✔
11
import { styleMap } from 'lit/directives/style-map.js';
10✔
12
import { themes } from '../../theming/theming-decorator.js';
10✔
13
import IgcIconButtonComponent from '../button/icon-button.js';
10✔
14
import {
10✔
15
  addKeybindings,
10✔
16
  arrowLeft,
10✔
17
  arrowRight,
10✔
18
  endKey,
10✔
19
  homeKey,
10✔
20
} from '../common/controllers/key-bindings.js';
10✔
21
import {
10✔
22
  type MutationControllerParams,
10✔
23
  createMutationController,
10✔
24
} from '../common/controllers/mutation-observer.js';
10✔
25
import { createResizeController } from '../common/controllers/resize-observer.js';
10✔
26
import { watch } from '../common/decorators/watch.js';
10✔
27
import { registerComponent } from '../common/definitions/register.js';
10✔
28
import type { Constructor } from '../common/mixins/constructor.js';
10✔
29
import { EventEmitterMixin } from '../common/mixins/event-emitter.js';
10✔
30
import {
10✔
31
  findElementFromEventPath,
10✔
32
  first,
10✔
33
  isEmpty,
10✔
34
  isLTR,
10✔
35
  isString,
10✔
36
  last,
10✔
37
  partNameMap,
10✔
38
  scrollIntoView,
10✔
39
  wrap,
10✔
40
} from '../common/util.js';
10✔
41
import IgcTabComponent from './tab.js';
10✔
42
import { styles as shared } from './themes/shared/tabs/tabs.common.css.js';
10✔
43
import { all } from './themes/tabs-themes.js';
10✔
44
import { styles } from './themes/tabs.base.css.js';
10✔
45

10✔
46
export interface IgcTabsComponentEventMap {
10✔
47
  igcChange: CustomEvent<IgcTabComponent>;
10✔
48
}
10✔
49

10✔
50
/* blazorAdditionalDependency: IgcTabComponent */
10✔
51
/**
10✔
52
 * `IgcTabsComponent` provides a wizard-like workflow by dividing content into logical tabs.
10✔
53
 *
10✔
54
 * The tabs component allows the user to navigate between multiple tabs.
10✔
55
 * It supports keyboard navigation and provides API methods to control the selected tab.
10✔
56
 *
10✔
57
 * @element igc-tabs
10✔
58
 *
10✔
59
 * @fires igcChange - Emitted when the selected tab changes.
10✔
60
 *
10✔
61
 * @slot - Renders the `IgcTabComponents` inside default slot.
10✔
62
 *
10✔
63
 * @csspart start-scroll-button - The start scroll button displayed when the tabs overflow.
10✔
64
 * @csspart end-scroll-button - The end scroll button displayed when the tabs overflow.
10✔
65
 * @csspart selected-indicator - The indicator that shows which tab is selected.
10✔
66
 */
10✔
67
@themes(all)
10✔
68
export default class IgcTabsComponent extends EventEmitterMixin<
10✔
69
  IgcTabsComponentEventMap,
10✔
70
  Constructor<LitElement>
10✔
71
>(LitElement) {
10✔
72
  public static readonly tagName = 'igc-tabs';
10✔
73
  public static styles = [styles, shared];
10✔
74

10✔
75
  /* blazorSuppress */
10✔
76
  public static register() {
10✔
77
    registerComponent(
10✔
78
      IgcTabsComponent,
10✔
79
      IgcTabComponent,
10✔
80
      IgcIconButtonComponent
10✔
81
    );
10✔
82
  }
10✔
83

10✔
84
  private _resizeController!: ReturnType<typeof createResizeController>;
10✔
85
  private _headerRef: Ref<HTMLDivElement> = createRef();
10✔
86

10✔
87
  /** Used in the `update` hook to check if a dynamic ltr-rtl change has happened,
10✔
88
   * and calls `alignIndicator` if there is one.
10✔
89
   */
10✔
90
  private _isLtr = true;
10✔
91

10✔
92
  @query('[part~="tabs"]', true)
10✔
93
  protected _scrollContainer!: HTMLElement;
10✔
94

10✔
95
  @query('[part="selected-indicator"] span', true)
10✔
96
  protected _selectedIndicator!: HTMLElement;
10✔
97

10✔
98
  @state()
10✔
99
  private _activeTab?: IgcTabComponent;
10✔
100

10✔
101
  @state()
10✔
102
  private _cssVars = {
10✔
103
    '--_tabs-count': '',
10✔
104
    '--_ig-tabs-width': '',
10✔
105
  };
10✔
106

10✔
107
  @state()
10✔
108
  private _scrollButtonsDisabled = {
10✔
109
    start: true,
10✔
110
    end: false,
10✔
111
  };
10✔
112

10✔
113
  @state()
10✔
114
  protected showScrollButtons = false;
10✔
115

10✔
116
  private get _closestActiveTabIndex() {
10✔
117
    const root = this.getRootNode() as Document | ShadowRoot;
9✔
118
    const tab = root.activeElement
9✔
119
      ? root.activeElement.closest(IgcTabComponent.tagName)
9!
120
      : null;
×
121

9✔
122
    return this._enabledTabs.indexOf(tab!);
9✔
123
  }
9✔
124

10✔
125
  protected get _enabledTabs() {
10✔
126
    return this.tabs.filter((tab) => !tab.disabled);
60✔
127
  }
60✔
128

10✔
129
  /* blazorSuppress */
10✔
130
  @queryAssignedElements({ selector: IgcTabComponent.tagName })
10✔
131
  public tabs!: IgcTabComponent[];
10✔
132

10✔
133
  /** Returns the currently selected tab label or id if not label is present. */
10✔
134
  public get selected(): string {
10✔
135
    if (this._activeTab) {
2✔
136
      return this._activeTab.label || this._activeTab.id;
2!
137
    }
2!
NEW
138

×
NEW
139
    return '';
×
140
  }
2✔
141

10✔
142
  /**
10✔
143
   * Sets the alignment for the tab headers
10✔
144
   * @attr
10✔
145
   */
10✔
146
  @property({ reflect: true })
10✔
147
  public alignment: 'start' | 'end' | 'center' | 'justify' = 'start';
10✔
148

10✔
149
  /**
10✔
150
   * Determines the tab activation. When set to auto,
10✔
151
   * the tab is instantly selected while navigating with the Left/Right Arrows, Home or End keys
10✔
152
   * and the corresponding panel is displayed.
10✔
153
   * When set to manual, the tab is only focused. The selection happens after pressing Space or Enter.
10✔
154
   * @attr
10✔
155
   */
10✔
156
  @property()
10✔
157
  public activation: 'auto' | 'manual' = 'auto';
10✔
158

10✔
159
  @watch('alignment', { waitUntilFirstUpdate: true })
10✔
160
  protected alignmentChanged() {
10✔
161
    this._alignIndicator();
5✔
162
  }
5✔
163

10✔
164
  constructor() {
10✔
165
    super();
38✔
166

38✔
167
    addKeybindings(this, {
38✔
168
      ref: this._headerRef,
38✔
169
      skip: this._skipKeyboard,
38✔
170
      bindingDefaults: { preventDefault: true },
38✔
171
    })
38✔
172
      .set(arrowLeft, () => this.handleArrowKeys(isLTR(this) ? -1 : 1))
38✔
173
      .set(arrowRight, () => this.handleArrowKeys(isLTR(this) ? 1 : -1))
38✔
174
      .set(homeKey, this.handleHomeKey)
38✔
175
      .set(endKey, this.handleEndKey)
38✔
176
      .setActivateHandler(this.handleActivationKeys, { preventDefault: false });
38✔
177

38✔
178
    createMutationController(this, {
38✔
179
      callback: this._mutationCallback,
38✔
180
      config: {
38✔
181
        attributeFilter: ['selected'],
38✔
182
        childList: true,
38✔
183
        subtree: true,
38✔
184
      },
38✔
185
      filter: [IgcTabComponent.tagName],
38✔
186
    });
38✔
187

38✔
188
    this._resizeController = createResizeController(this, {
38✔
189
      callback: this._resizeCallback,
38✔
190
      options: { box: 'border-box' },
38✔
191
      target: null,
38✔
192
    });
38✔
193
  }
38✔
194

10✔
195
  protected override async firstUpdated() {
10✔
196
    await this.updateComplete;
38✔
197

38✔
198
    const selectedTab =
38✔
199
      this.tabs.findLast((tab) => tab.selected) ?? first(this._enabledTabs);
38✔
200

38✔
201
    this._setCssProps();
38✔
202
    this._updateButtonsOnResize();
38✔
203
    this._selectTab(selectedTab, false);
38✔
204

38✔
205
    this._resizeController.observe(this._scrollContainer);
38✔
206
  }
38✔
207

10✔
208
  public override connectedCallback() {
10✔
209
    super.connectedCallback();
38✔
210
    this.role = 'tablist';
38✔
211
  }
38✔
212

10✔
213
  protected override updated() {
10✔
214
    if (this._isLtr !== isLTR(this)) {
241✔
215
      this._isLtr = isLTR(this);
5✔
216
      this._alignIndicator();
5✔
217
    }
5✔
218
  }
241✔
219

10✔
220
  private async _resizeCallback() {
10✔
221
    this._updateButtonsOnResize();
25✔
222
    await this.updateComplete;
25✔
223
    this._alignIndicator();
25✔
224
    this._setCssProps();
25✔
225
  }
25✔
226

10✔
227
  private _mutationCallback({
10✔
228
    changes,
80✔
229
  }: MutationControllerParams<IgcTabComponent>) {
80✔
230
    const selected = changes.attributes.find(
80✔
231
      (tab) => this.tabs.includes(tab) && tab.selected
80✔
232
    );
80✔
233
    const added = changes.added.filter(
80✔
234
      (change) => change.target.closest(this.tagName) === this
80✔
235
    );
80✔
236
    const removed = changes.removed.filter(
80✔
237
      (change) => change.target.closest(this.tagName) === this
80✔
238
    );
80✔
239

80✔
240
    this._selectTab(selected, false);
80✔
241

80✔
242
    if (!(isEmpty(removed) && isEmpty(added))) {
80✔
243
      let nextSelectedTab: IgcTabComponent | null = null;
7✔
244

7✔
245
      for (const { node: tab } of removed) {
7✔
246
        if (tab.selected && tab === this._activeTab) {
3✔
247
          nextSelectedTab = first(this._enabledTabs);
1✔
248
        }
1✔
249
      }
3✔
250

7✔
251
      for (const { node: tab } of added) {
7✔
252
        if (tab.selected) {
5✔
253
          nextSelectedTab = tab;
2✔
254
        }
2✔
255
      }
5✔
256

7✔
257
      this._setCssProps();
7✔
258
      this._updateButtonsOnResize();
7✔
259

7✔
260
      if (nextSelectedTab) {
7✔
261
        this._selectTab(nextSelectedTab, false);
3✔
262
      }
3✔
263

7✔
264
      this._alignIndicator();
7✔
265
    }
7✔
266
  }
80✔
267

10✔
268
  private async _alignIndicator() {
10✔
269
    const styles: Partial<CSSStyleDeclaration> = {
110✔
270
      visibility: this._activeTab ? 'visible' : 'hidden',
110✔
271
    };
110✔
272

110✔
273
    await this.updateComplete;
110✔
274

110✔
275
    if (this._activeTab) {
110✔
276
      const activeTabHeader = this._getTabHeader(this._activeTab);
109✔
277

109✔
278
      const headerRect = activeTabHeader.getBoundingClientRect();
109✔
279
      const containerRect = this._scrollContainer.getBoundingClientRect();
109✔
280

109✔
281
      const headerOffset = activeTabHeader.offsetLeft;
109✔
282
      const containerOffset = this._scrollContainer.offsetLeft;
109✔
283

109✔
284
      const translateX = isLTR(this)
109✔
285
        ? headerOffset - containerOffset
92✔
286
        : headerRect.width + headerOffset - containerRect.width;
17✔
287

109✔
288
      Object.assign(styles, {
109✔
289
        width: `${headerRect.width}px`,
109✔
290
        transform: `translateX(${translateX}px)`,
109✔
291
      });
109✔
292
    }
109✔
293

110✔
294
    Object.assign(this._selectedIndicator.style, styles);
110✔
295
  }
110✔
296

10✔
297
  private _updateButtonsOnResize() {
10✔
298
    const { scrollWidth, clientWidth } = this._scrollContainer;
70✔
299

70✔
300
    this.showScrollButtons = scrollWidth > clientWidth;
70✔
301
    this._updateScrollButtons();
70✔
302
  }
70✔
303

10✔
304
  private _updateScrollButtons() {
10✔
305
    const { scrollLeft, scrollWidth } = this._scrollContainer;
143✔
306
    const { width } = this._scrollContainer.getBoundingClientRect();
143✔
307

143✔
308
    this._scrollButtonsDisabled = {
143✔
309
      start: scrollLeft === 0,
143✔
310
      end: Math.abs(Math.abs(scrollLeft) + width - scrollWidth) <= 1,
143✔
311
    };
143✔
312
  }
143✔
313

10✔
314
  private _selectTab(tab?: IgcTabComponent, shouldEmit = true) {
10✔
315
    if (!tab || tab === this._activeTab) {
151✔
316
      return;
83✔
317
    }
83✔
318

68✔
319
    this._setSelectedTab(tab);
68✔
320
    if (shouldEmit) {
151✔
321
      this.emitEvent('igcChange', { detail: this._activeTab });
13✔
322
    }
13✔
323
  }
151✔
324

10✔
325
  private _setSelectedTab(tab: IgcTabComponent) {
10✔
326
    if (this._activeTab) {
68✔
327
      this._activeTab.selected = false;
31✔
328
    }
31✔
329

68✔
330
    tab.selected = true;
68✔
331
    this._activeTab = tab;
68✔
332

68✔
333
    scrollIntoView(this._getTabHeader(tab));
68✔
334
    this._alignIndicator();
68✔
335
  }
68✔
336

10✔
337
  private _scrollByOffset(direction: 'start' | 'end') {
10✔
338
    const factor = isLTR(this) ? 1 : -1;
5✔
339
    const step = direction === 'start' ? -180 : 180;
5✔
340

5✔
341
    this._setScrollSnap(direction);
5✔
342
    this._scrollContainer.scrollBy({ left: step * factor, behavior: 'smooth' });
5✔
343
  }
5✔
344

10✔
345
  private _keyboardActivateTab(tab: IgcTabComponent) {
10✔
346
    const header = this._getTabHeader(tab);
14✔
347

14✔
348
    this._setScrollSnap('unset');
14✔
349
    scrollIntoView(header);
14✔
350
    header.focus({ preventScroll: true });
14✔
351

14✔
352
    if (this.activation === 'auto') {
14✔
353
      this._selectTab(tab);
9✔
354
    }
9✔
355
  }
14✔
356

10✔
357
  private _skipKeyboard(node: Element, event: KeyboardEvent) {
10✔
358
    return !(
30✔
359
      this._isEventFromTabHeader(event) &&
30✔
360
      this.tabs.includes(node.closest(IgcTabComponent.tagName)!)
28✔
361
    );
30✔
362
  }
30✔
363

10✔
364
  private _isEventFromTabHeader(event: Event) {
10✔
365
    return findElementFromEventPath('[part~="tab-header"]', event);
38✔
366
  }
38✔
367

10✔
368
  private _getTabHeader(tab: IgcTabComponent) {
10✔
369
    return tab.renderRoot.querySelector<HTMLElement>('[part~="tab-header"]')!;
196✔
370
  }
196✔
371

10✔
372
  private _setCssProps() {
10✔
373
    this._cssVars = {
70✔
374
      '--_tabs-count': this.tabs.length.toString(),
70✔
375
      '--_ig-tabs-width': `${this._scrollContainer.getBoundingClientRect().width}px`,
70✔
376
    };
70✔
377
  }
70✔
378

10✔
379
  private _setScrollSnap(value: 'start' | 'end' | 'unset') {
10✔
380
    this._scrollContainer.style.setProperty('--_ig-tab-snap', value);
24✔
381
  }
24✔
382

10✔
383
  protected handleClick(event: PointerEvent) {
10✔
384
    if (!this._isEventFromTabHeader(event)) {
8✔
385
      return;
1✔
386
    }
1✔
387

7✔
388
    const tab = findElementFromEventPath<IgcTabComponent>(
7✔
389
      IgcTabComponent.tagName,
7✔
390
      event
7✔
391
    );
7✔
392

7✔
393
    if (!this.tabs.includes(tab!) || tab?.disabled) {
8✔
394
      return;
2✔
395
    }
2✔
396

5✔
397
    this._setScrollSnap('unset');
5✔
398
    this._getTabHeader(tab!).focus();
5✔
399
    this._selectTab(tab);
5✔
400
  }
8✔
401

10✔
402
  protected handleArrowKeys(delta: -1 | 1) {
10✔
403
    const tabs = this._enabledTabs;
7✔
404
    this._keyboardActivateTab(
7✔
405
      tabs[wrap(0, tabs.length - 1, this._closestActiveTabIndex + delta)]
7✔
406
    );
7✔
407
  }
7✔
408

10✔
409
  protected handleHomeKey() {
10✔
410
    this._keyboardActivateTab(first(this._enabledTabs));
2✔
411
  }
2✔
412

10✔
413
  protected handleEndKey() {
10✔
414
    this._keyboardActivateTab(last(this._enabledTabs));
3✔
415
  }
3✔
416

10✔
417
  protected handleActivationKeys() {
10✔
418
    const tabs = this._enabledTabs;
2✔
419
    const index = this._closestActiveTabIndex;
2✔
420

2✔
421
    if (index > -1) {
2✔
422
      this._selectTab(tabs[index], false);
2✔
423
      this._keyboardActivateTab(tabs[index]);
2✔
424
    }
2✔
425
  }
2✔
426

10✔
427
  @eventOptions({ passive: true })
10✔
428
  protected handleScroll() {
10✔
429
    this._updateScrollButtons();
73✔
430
  }
73✔
431

10✔
432
  /** Selects the specified tab and displays the corresponding panel.  */
10✔
433
  public select(tab: IgcTabComponent | string) {
10✔
434
    const element = isString(tab) ? this.tabs.find((t) => t.id === tab) : tab;
15✔
435

15✔
436
    if (element) {
15✔
437
      this._selectTab(element, false);
14✔
438
    }
14✔
439
  }
15✔
440

10✔
441
  protected renderScrollButton(direction: 'start' | 'end') {
10✔
442
    const start = direction === 'start';
482✔
443

482✔
444
    return this.showScrollButtons
482✔
445
      ? html`
204✔
446
          <igc-icon-button
204✔
447
            tabindex="-1"
204✔
448
            variant="flat"
204✔
449
            collection="default"
204✔
450
            part="${direction}-scroll-button"
204✔
451
            exportparts="icon"
204✔
452
            name="${start ? 'prev' : 'next'}"
204✔
453
            ?disabled=${start
204✔
454
              ? this._scrollButtonsDisabled.start
102✔
455
              : this._scrollButtonsDisabled.end}
204✔
456
            @click=${() => this._scrollByOffset(direction)}
204✔
457
          >
278✔
458
          </igc-icon-button>
278✔
459
        `
278✔
460
      : nothing;
278✔
461
  }
482✔
462

10✔
463
  protected override render() {
10✔
464
    const part = partNameMap({
241✔
465
      inner: true,
241✔
466
      scrollable: this.showScrollButtons,
241✔
467
    });
241✔
468

241✔
469
    return html`
241✔
470
      <div
241✔
471
        ${ref(this._headerRef)}
241✔
472
        part="tabs"
241✔
473
        style=${styleMap(this._cssVars)}
241✔
474
        @scroll=${this.handleScroll}
241✔
475
      >
241✔
476
        <div part=${part}>
241✔
477
          ${this.renderScrollButton('start')}
241✔
478

241✔
479
          <slot @click=${this.handleClick}></slot>
241✔
480

241✔
481
          ${this.renderScrollButton('end')}
241✔
482

241✔
483
          <div part="selected-indicator">
241✔
484
            <span></span>
241✔
485
          </div>
241✔
486
        </div>
241✔
487
      </div>
241✔
488
    `;
241✔
489
  }
241✔
490
}
10✔
491

10✔
492
declare global {
10✔
493
  interface HTMLElementTagNameMap {
10✔
494
    'igc-tabs': IgcTabsComponent;
10✔
495
  }
10✔
496
}
10✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc