Override default click on Angular Material Tab

Benjamin Thieblot

· ––– views

Welcome to the first Angular Tips post of this blog. With this series, we will discover how we can enhance our knowledge of Angular (and also Angular Material) to make things of our own.

This first post will discuss of something very simple, so let's get into it.

Angular Material Tab

If you are here it's because you are searching a way of overriding the default behavior of the tab component when a user click on one of our tab child.

By default, Angular Material will update the indexed of the selected tab, and thus, changing the component we are currently displaying. We can see it on their Github.

/** Handle click events, setting new selected index if appropriate. */
_handleClick(tab: MatTab, tabHeader: MatTabGroupBaseHeader, index: number) {
  if (!tab.disabled) {
    this.selectedIndex = tabHeader.focusIndex = index;
  }
}
typescript

But, even if Material is not easy to customize, we have some ways to override default behaviors.

The ViewChild solution

One of the key element to solve this problem is to get a reference of the MatTabGroup element inside our html.

<mat-tab-group>...</mat-tab-group>
html

To do so, we will use the @ViewChild decorator to access the element.

@ViewChild(MatTabGroup)
set tabGroup(el: MatTabGroup) {
  ...
}
typescript

We use it with the decorator, and we then can access it via a variable. But if we do so, we will have some problems related to the fact that our variable is undefined and not yet available.

To counter this, we use a setter function with the keyword set to call this function every time the ViewChild emit something new. The first values emitted will be undefined but after the DOM is rendered, the function will have a defined value of the current MatTabGroup displayed.

Overriding functions

Now that we have access to the MatTabGroup variable, we can do some work inside of it to override the default behavior.

Usually, when I want to check if I can override Material behavior, I access the variable associated to the Material element, and check inside the class if I can use some properties, functions, methods.. to do as I wish.

For this case, if you open the definition of the tab-group.d.ts by clicking on Cmd+ the MatTabGroup property, you'll have access to this:

export declare class MatTabGroup extends _MatTabGroupBase {
    _allTabs: QueryList<MatTab>;
    _tabBodyWrapper: ElementRef;
    _tabHeader: MatTabGroupBaseHeader;
    ...
}
typescript

Inside of it, nothing important for us, but we can see that the MatTabGroup class extends another class, the "base" one. By going to the definition of the base class, we now have access to a lot of methods, properties to deal with.

export declare abstract class _MatTabGroupBase extends _MatTabGroupMixinBase implements AfterContentInit, AfterContentChecked, OnDestroy, CanColor, CanDisableRipple {
    ...
    
    get dynamicHeight(): boolean;
    set dynamicHeight(value: BooleanInput);
    private _dynamicHeight;
    /** The index of the active tab. */
    get selectedIndex(): number | null;
    set selectedIndex(value: NumberInput);
    private _selectedIndex;
    /** Position of the tab header. */
    headerPosition: MatTabHeaderPosition;
    /** Duration for the tab animation. Will be normalized to milliseconds if no units are set. */
    get animationDuration(): string;
    set animationDuration(value: NumberInput);
    private _animationDuration;
    /**
     * `tabindex` to be set on the inner element that wraps the tab content. Can be used for improved
     * accessibility when the tab does not have focusable elements or if it has scrollable content.
     * The `tabindex` will be removed automatically for inactive tabs.
     * Read more at https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-2/tabs.html
     */
    get contentTabIndex(): number | null;
    set contentTabIndex(value: NumberInput);
    private _contentTabIndex;
    
    ...
    
    constructor(elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef, defaultConfig?: MatTabsConfig, _animationMode?: string | undefined);
    /**
     * After the content is checked, this component knows what tabs have been defined
     * and what the selected index should be. This is where we can know exactly what position
     * each tab should be in according to the new selected index, and additionally we know how
     * a new selected tab should transition in (from the left or right).
     */
    ngAfterContentChecked(): void;
    ngAfterContentInit(): void;
    /** Listens to changes in all of the tabs. */
    private _subscribeToAllTabChanges;
    ngOnDestroy(): void;
    /** Re-aligns the ink bar to the selected tab element. */
    realignInkBar(): void;
    
    ...
    
    /** Removes the height of the tab body wrapper. */
    _removeTabBodyWrapperHeight(): void;
    /** Handle click events, setting new selected index if appropriate. */
    _handleClick(tab: MatTab, tabHeader: MatTabGroupBaseHeader, index: number): void;
    /** Retrieves the tabindex for the tab. */
    _getTabIndex(tab: MatTab, index: number): number | null;
    /** Callback for when the focused state of a tab has changed. */
    _tabFocusChanged(focusOrigin: FocusOrigin, index: number): void;
}
typescript

I deliberately omitted some properties not important for our use case, but you can check all the class inside their Github.

However, we can see on line 49, the _handleClick method, which is not private and thus, accessible from outside the class. We already saw that this method is responsible of dealing with the click event of the user on the tab group.

So, let's put the final piece.

Wrapping everything up

To achieve what we want: overriding the default click of the mat tab group, and set something else to determine what is the currently active tab; we can now mix all of the solution with this:

@ViewChild(MatTabGroup)
set tabGroup(el: MatTabGroup) {
  if (el) {
    /**
     * Override default click handler on Tab click
     * With this, instead of changing the tab by clicking on it and letting the
     * MatTabComponent do it's work we change the URL and based on this info,
     * we change the index of the selected tab
     */
    el._handleClick = (_tab, _tabHeader, index) => {
      const newTab = 'tab-number' + index;
      // For my purpose, I wanted to update the url when a user click on a tab child
      // And get the currently active tab
      this.navigate({ urlQuery: newTab });
    };
  }
  // We set the MatTabGroup inside a variable if we want to access it from elsewhere
  this.matTabGroup = el;
}
//
//
//
// Observable<number>
this.activeTab$ = this.route.queryParamMap.pipe(
  map(paramMap => {
    const currentTab = paramMap.get('urlQuery') ?? defaultTab;
    return ['tab-number-1', 'tab-number-2', 'tab-number-3']
      .findIndex(val => val === currentTab);
  })
);
typescript

So now, when a user click on a mat tab element, we will navigate to another url by changing a query param. When the query param change (by using the ActivatedRoute package), we have created an observable that hold the index of the current tab.

All we have to do now is to react to this observable and change the active tab.

<mat-tab-group [selectedIndex]="activeTab$ | async">...</mat-tab-group>
html

Et voilà.

Thank you for reaching the end of my first post, see you for another tips in Angular.

Updated: