feat(tabs): Enable rendering tabs list and tab content in independent locations
Feature Description
Currently, MatTabGroup renders the tab content immediately below the list of tab labels:
[ Tab 1 ] [ Tab 2 ] [ Tab 3 ]
-----------------------------
Tab content
This new feature will enable consumers to specify a separate location where the tab content should be rendered.
Use Case
The goal is to render content between the tab labels and tab content.
Example:
[ Tab 1 ] [ Tab 2 ] [ Tab 3 ] [ Button ]
---------------------------------------------------
Tab content
Usage example:
<mat-tab-group>
<mat-tab label="Tab 1">Content 1</mat-tab>
<mat-tab label="Tab 2">Content 2</mat-tab>
<mat-tab label="Tab 3">Content 3</mat-tab>
</mat-tab-group>
<button>Button</button>
<mat-tab-group-body></mat-tab-group-body>
Contribution
I have an in progress PR that I will link to this issue.
Problem
The MatTabGroup component encapsulates both the tab header and tab body. Observe that the tab group template is composed of two components: MatTabHeader and MatTabBody. Both of these components are private to the Angular Material library. Specifically, they are not exported in the tabs module. Therefore, there is no mechanism to consume and render these parts. Even if they were exposed, the MatTabGroup component contains logic required for operation of the tabs (I’ll refer to as tab group logic). This tab group logic includes inputs/outputs (public API), managing state, generating common unique ids for attributes, and handling tab selection. This is non-trivial logic that would need to be reproduced by the consumer.
Recommended Solution
Create two new components: MatTabGroupHeader and MatTabGroupBody. Each will encapsulate the header and body subcomponents of the MatTabGroup.
MatTabGroupHeader will also contain much of the logic that exists within MatTabGroup that cannot be moved to one of the subcomponents. This includes such logic as managing selected tab state and managing MatTab content children.
The MatTabGroupHeader has a body input that accepts a reference to a MatTabGroupBody instance. With this reference, MatTabGroupHeader initializes the MatTabGroupBody with all necessary information to render content associated with the MatTabGroupHeader's tabs.
Usage example:
<mat-tab-group-header [body]="#Body">
<mat-tab label="Tab 1">Content 1</mat-tab>
<mat-tab label="Tab 2">Content 2</mat-tab>
<mat-tab label="Tab 3">Content 3</mat-tab>
</mat-tab-group-header>
<mat-tab-group-body #body></mat-tab-group-body>
See WIP Pull Request for more details: https://github.com/angular/components/pull/23405
Alternatives Considered
1. Export existing subcomponents
MatTabGroup is composed of MatTabHeader and MatTabBody. Currently, both of these subcomponents are internal. If these subcomponents are exported for public consumption, a consumer can construct a component that allows rendering header and body in independent locations.
Pros:
- Simple Cons:
- Consumer must implement non-trivial "tab group" logic, or duplicate existing logic in
MatTabGroup - Exposes the subcomponent's internal contracts that weren't intended for public consumption
2. Create public subcomponents
Instead of exporting the private subcomponents, create two new components that wrap the private subcomponents.
Any MatTabGroup logic that is specific to one of these components can be extracted into the new component. MatTabGroup can be refactored to be a composition of these two subcomponents.
Consumers of these subcomponents will be responsible for implementing some fo the logic that exists in MatTabGroup, but unlike option 1, the logic will be limited to that which connects the subcomponents.
Usage example:
// component.html
<mat-tab-group-header #tabHeader
[tabs]="_tabs"
[groupId]="_groupId"
(selectedIndexChange)="handleSelectedIndexChange($event)"
[disableRipple]="disableRipple">
</mat-tab-group-header>
<mat-tab-group-body #tabBodyContent
[tabs]="_tabs"
[selectedIndex]="selectedIndex"
[animationMode]="_animationMode">
</mat-tab-group-body>
// component.ts
@ContentChildren(MatTab) _tabs = new QueryList<MatTab>;
@ViewChild('tabHeader') _tabHeader;
@ViewChild('tabBody') _tabBody;
@Input() selectedIndex = 0;
private _groupId = ++nextId;
handleSelectedIndexChange(index: number) {
/* … */
}
Pros:
- Reduced burden on consumers (compared to option 1)
- Create a public interface while keeping the existing internal subcomponent's interfaces private Cons:
- Although reduced, the remaining tab group logic is non-trivial and easy to get wrong. For example, see the logic for updating the selected index.
3. Create decoupled MatTabGroupHeader and MatTabGroupBody components
Like the recommended solution, create two new components: MatTabGroupHeader and MatTabGroupBody. Unlike the recommended solution, MatTabGroupHeader will not handle connecting the MatTabGroupBody. Instead, the consumer will be responsible for connecting the components in the template.
Usage example:
<mat-tab-group-header #tabGroupHeader
[disablePagination]="true">
<mat-tab label="First">Content 1</mat-tab>
<mat-tab label="Second">Content 2</mat-tab>
<mat-tab label="Third">Content 3</mat-tab>
</mat-tab-group-header>
<mat-tab-group-body [tabs]="tabGroupHeader.tabs"
[groupId]="tabGroupHeader.groupId"
[selectedIndex$]="tabGroupHeader.selectedIndexObs"
animationDuration="1000ms">
</mat-tab-group-body>
Pros:
- Consumer does not need to implement any tab group logic Cons:
- Consumers are required to correctly connect components in the template
4. MatTabGroup with optional body input
Create a new MatTabGroupBody component. But instead of creating a new MatTabGroupHeader, update the existing MatTabGroup. Add an optional body input to the component that accepts a MatTabGroupBody reference.
When a body reference is passed, MatTabGroup will render tab content to the MatTabGroupBody instead of to its internally defined body.
Pros:
- Reuses existing
MatTabGroupCons: - Adds complexity to
MatTabGroupwith two code paths: 1. render internal tab body, 2. render external tab body
Notes
- All options presented will lead to cases of duplicate interfaces and logic. However, duplication can be reduced via abstract classes and mixins. For example: MatTabGroup’s interface can be thought of as a partial intersection type of
MatTabGroupHeader & MatTabGroupBody
Updating design based on discussions with @andrewseguin.
2 new components will be extracted from MatTabGroup. They are MatTabList and MatTabPanel (to match naming of the ARIA pattern).
Both components will be mostly stateless. For example, MatTabList the currently selected tab will be determined by the selectedIndex input. When a tab is clicked, the index of the clicked tab will emit via the selectedIndexChange output. But selected index state will not be stored within the MatTabList.
MatTabGroup will be refactored as a composition of these two components.
Finally, for consumers who desire rendering tab list and tab panel independently, we'll provide a service that will manage syncing state between the tab list and tab panel (as the MatTabGroup component currently does).
Example Usage:
Intentionally excludes the sync service (details of which are TBD).
<mat-tab-list>
<mat-tab-list-label> Tab 1 </mat-tab-list-label>
<mat-tab-list-label> Tab 2 </mat-tab-list-label>
<mat-tab-list-label> Tab 2 </mat-tab-list-label>
</mat-tab-list>
<mat-tab-panel>
<mat-tab-panel-content> Tab 1 Content </mat-tab-panel-content>
<mat-tab-panel-content> Tab 2 Content </mat-tab-panel-content>
<mat-tab-panel-content> Tab 3 Content </mat-tab-panel-content>
</mat-tab-panel>
Steps
- Extract tab-list component
- Extract tab-panel component
- Create sync service
Just a heads up that we kicked off a community voting process for your feature request. There are 20 days until the voting process ends.
Find more details about Angular's feature request process in our documentation.
Thank you for submitting your feature request! Looks like during the polling process it didn't collect a sufficient number of votes to move to the next stage.
We want to keep Angular rich and ergonomic and at the same time be mindful about its scope and learning journey. If you think your request could live outside Angular's scope, we'd encourage you to collaborate with the community on publishing it as an open source package.
You can find more details about the feature request process in our documentation.
Any updates on this?