angular / components

Component infrastructure and Material Design components for Angular
https://material.angular.io
MIT License
24.35k stars 6.74k forks source link

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

Open byanofsky opened 3 years ago

byanofsky commented 3 years ago

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 commented 3 years ago

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:

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:

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:

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:

Notes

byanofsky commented 3 years ago

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
angular-robot[bot] commented 2 years ago

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] commented 2 years ago

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.

6apc1k commented 3 weeks ago

Any updates on this?