components icon indicating copy to clipboard operation
components copied to clipboard

feat(tabs): Enable rendering tabs list and tab content in independent locations

Open byanofsky opened this issue 4 years ago • 5 comments

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.

byanofsky avatar Aug 18 '21 23:08 byanofsky

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 MatTabGroup Cons:
  • Adds complexity to MatTabGroup with 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

byanofsky avatar Aug 18 '21 23:08 byanofsky

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

  1. Extract tab-list component
  2. Extract tab-panel component
  3. Create sync service

byanofsky avatar Oct 11 '21 20:10 byanofsky

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.

angular-robot[bot] avatar Mar 13 '22 15:03 angular-robot[bot]

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.

angular-robot[bot] avatar Apr 01 '22 15:04 angular-robot[bot]

Any updates on this?

6apc1k avatar Sep 30 '24 16:09 6apc1k