-
Notifications
You must be signed in to change notification settings - Fork 6.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(cdk-experimental/ui-patterns): tabs ui pattern #30568
base: main
Are you sure you want to change the base?
Conversation
src/cdk-experimental/tabs/tabs.ts
Outdated
'[attr.tabindex]': 'pattern.tabindex()', | ||
'[attr.aria-disabled]': 'pattern.disabled()', | ||
'[attr.aria-orientation]': 'pattern.orientation()', | ||
'[attr.aria-multiselectable]': 'pattern.multiselectable()', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you share a link to the reference for multiselectable tabs? My suspicion is that even in situations where tabs are multiselectable, only one tab's content is displayed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
https://w3c.github.io/aria/#tablist specified aria-multiselectable
is a supported attribute for tablist
role, but so far I can't find a working example of a multi-selectable tabs.
However while searching around I found this w3c/aria#1355, so it is unclear at this point what a multi-selectable tabs will look like.
Based on the above issue and per our previous discussion, I removed the multi-selectable part from the tabs ui pattern.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the pattern exists. I imagine it works similar to how multiselecting tabs in the browser works. We should document this as a potential feature we want to add in the future
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good. I will include in the tabs pattern design doc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO we should exclude this from a the first iteration and consider as a possible feature addition later.
src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.css
Outdated
Show resolved
Hide resolved
}) | ||
export class CdkTabs { | ||
/** The CdkTabs nested inside of the container. */ | ||
private readonly _cdkTabs = contentChildren(CdkTab, {descendants: true}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we start without having descendants: true
? The problem is that this will also pick up tabs and panels inside of nested tabs, which apparently is somewhat common.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right, we shouldn't include the nested tabs/tabpanels, those are supposed to be controlled by another CdkTabs directive. Fixed.
src/cdk-experimental/tabs/tabs.ts
Outdated
'[attr.aria-orientation]': 'pattern.orientation()', | ||
'[attr.aria-activedescendant]': 'pattern.activedescendant()', | ||
'(keydown)': 'pattern.onKeydown($event)', | ||
'(mousedown)': 'pattern.onPointerdown($event)', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we use pointer events here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops fixed.
src/cdk-experimental/tabs/tabs.ts
Outdated
'role': 'tabpanel', | ||
'class': 'cdk-tabpanel', | ||
'[attr.id]': 'pattern.id()', | ||
'[attr.aria-hidden]': 'pattern.hidden()', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we use inert
instead of aria-hidden
? The former will also block focus and keyboard events from entering the hidden content.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that's a good idea and inert
makes it removed from the a11y tree. What I am not sure is how much customization we want to leave to developers.
aria-hidden
: developers handle both visualization and interaction.inert
: developers handle visualization.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think there's a use case where the off-screen tabs shouldn't be inert so I think we can start with that and see if users ask for more customization.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good. Changed to inert
instead.
} | ||
|
||
/** Navigates to the first option in the tablist. */ | ||
first(opts?: SelectOptions) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we have the goto
method, do we need first
, last
, next
and prev
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This part is mostly followed the implementation in https://github.com/angular/components/blob/ef62d5c8e37bb7cc0b7ca9f8c5662593516f295f/src/cdk-experimental/ui-patterns/listbox/listbox.ts for consistency.
I find the current design very readable and each navigation method is thin and light. With only goto
method, I guess we will need an input for direction?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's leave it for now, but I think we should revisit it both for tabs and list.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are really convenience methods, but they also handle subtle complexity around skipping disabled items and wrapping
f82703c
to
eed8a03
Compare
'class': 'cdk-tabs', | ||
}, | ||
}) | ||
export class CdkTabs { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a little unsure about having CdkTabs
as a necessary wrapper / container vs. the tabpanel
having a reference/input for the tablist
, implying a 1:1 relationship between tablist and tabpanel.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was going to include the decision making of choosing a top-level wrapper in the design doc for later. Here's a quick comparison.
Passing references
<ul cdkTablist>
<li cdkTab #tab1="tab">tab1</li>
<li cdkTab #tab2="tab">tab2</li>
</ul>
<div cdkTabpanel [for]="tab1"></div>
<div cdkTabpanel [for]="tab2"></div>
Note that the template variable referencing can be reversed. The only dealbreaker I can think of is the dynamic generated tabs or tabpanels via control flow may be impossible. Another downside is the templating effort of creating template references for general use case.
The wrapper solution on the other hand can support both implicit and explicit(yet implemented, but likely via a string identifier other than id) tab-tabpanel binding, which I think it's more intuitive. I also found the same pattern used in most similar libraries, so it can be familiar to developers as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think we should probably have a discussion on the API(s) we expect an end-developer to define for their own tab implementations, and then how to best accommodate that with the directives. FWIW I think we will have a requirement that there may be elements between the tablist and the tabpanel(s).
For content, I suspect these directives will need to end up being oriented around rendering <ng-template>
content.
skipDisabled = input(true, {transform: booleanAttribute}); | ||
|
||
/** The focus strategy used by the tablist. */ | ||
focusMode = input<'roving' | 'activedescendant'>('roving'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should support activedescendant
for tabs; I'm not aware of any case where you'd want that instead of roving tabindex
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed it's probably not common. Although I did find a combobox+tabs example https://ariakit.org/examples/combobox-tabs
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh wow, that's wild. I guess there's no real harm if roving tabindex if the default
src/cdk-experimental/tabs/tabs.ts
Outdated
disabled = input(false, {transform: booleanAttribute}); | ||
|
||
/** The ids of the current selected tab. */ | ||
selectedIds = model<string[]>([]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should only support a single selected tab in this initial version.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sounds good. removed
src/cdk-experimental/tabs/tabs.ts
Outdated
}); | ||
|
||
/** The Tab UIPatterns of the child Tabs. */ | ||
protected items = computed(() => this._cdkTabs().map(tab => tab.pattern)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
protected items = computed(() => this._cdkTabs().map(tab => tab.pattern)); | |
protected tabs = computed(() => this._cdkTabs().map(tab => tab.pattern)); |
Call this tabs
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
id: Signal<string>; | ||
|
||
/** The position of the tab in the list. */ | ||
index = computed( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should index
be on the public API of TabPattern
? It feels like perhaps an implementation detail leaking through
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed. It's actually unused. I added the index
to cdk and forgot to clean this up. :p
/** The required inputs to tabs. */ | ||
export interface TabInputs extends ListNavigationItem, ListSelectionItem, ListFocusItem { | ||
tablist: Signal<TablistPattern>; | ||
tabpanel: Signal<TabpanelPattern>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems odd to me that the tab would need to know about the tabpanel. I was imagining the relationship would be that the tabpanel
only knows about the value of the tablist
, not necessarily any of the specifics tabs
import {ListFocus, ListFocusItem} from '../behaviors/list-focus/list-focus'; | ||
import {TabpanelPattern} from './tabpanel'; | ||
|
||
interface TablistPattern { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This interface is not connected to from the real implementation of TablistPattern
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oops that's right. fixed.
/** Whether the tabpanel is hidden. */ | ||
hidden = computed(() => !this.tab().selected()); | ||
|
||
constructor(args: TabpanelInputs) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
constructor(args: TabpanelInputs) { | |
constructor(inputs: TabpanelInputs) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
'role': 'tabpanel', | ||
'class': 'cdk-tabpanel', | ||
'[attr.id]': 'pattern.id()', | ||
'[attr.inert]': 'pattern.hidden() ? true : null', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a comment explaining the purpose of the inert
attribute in this context? Any links for what this is based on? (I haven't seen this approach before)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added comment to the JSDoc for the CdkTabpanel.
src/cdk-experimental/tabs/tabs.ts
Outdated
private readonly _cdkTablist = inject(CdkTablist); | ||
|
||
/** A unique identifier for the tab. */ | ||
private readonly _generatedId = inject(_IdGenerator).getId('cdk-tab-'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
private readonly _generatedId = inject(_IdGenerator).getId('cdk-tab-'); | |
private readonly _id = inject(_IdGenerator).getId('cdk-tab-'); |
Based on #30597, you should be able to drop the unnecessary computed and just pass () => this._id
to satisfy the type for the ID to the pattern
(and similar for the other unnecessary computeds)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done, and also replaced some unnecessary computeds with pure functions.
} | ||
|
||
/** A tabpanel associated with a tab. */ | ||
export class TabpanelPattern { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is probably a bigger conversation, but I wonder whether it makes sense to have a tabpanel
pattern at all, since it doesn't actually do or render anything.
'class': 'cdk-tabs', | ||
}, | ||
}) | ||
export class CdkTabs { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think we should probably have a discussion on the API(s) we expect an end-developer to define for their own tab implementations, and then how to best accommodate that with the directives. FWIW I think we will have a requirement that there may be elements between the tablist and the tabpanel(s).
For content, I suspect these directives will need to end up being oriented around rendering <ng-template>
content.
* <div cdkTabpanel>Tab content 1</div> | ||
* <div cdkTabpanel>Tab content 2</div> | ||
* <div cdkTabpanel>Tab content 3</div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we have any info on whether having one tabpanel element for each tab is strictly necessary, or can there be one tabpanel that changes attributes/content?
cc @crisbeto I vaguely recall this has come up before but I don't remember the details. Do you happen to recall anything?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hidden tabpanels should have at least aria-hidden
attribute, from the accessibility tree perspective there will be only one tabpanel, so having one or one per tab are likely the same. Most of the ARIA examples have one tabpanel per tab due to the static content.
It's probably fine supporting both ways
<!-- For static tabs -->
<div cdkTabs>
<ul cdkTablist>
<li cdkTab>tab 1</li>
<li cdkTab>tab 2</li>
</ul>
<div cdkTabpanel>content 1</div>
<div cdkTabpanel>content 2</div>
<!-- Or structural directives -->
@for (tabpanel of tabpanels) {
<div cdkTabpanel>{{tabpanel.content}}</div>
}
</div>
<!-- Or for deferred content -->
<div cdkTabs>
<ul cdkTablist #tablist>
<li cdkTab tab="tab1">tab 1</li>
<li cdkTab tab="tab2">tab 2</li>
</ul>
<!-- Just an ideal, CdkTablist does not expose selection yet. -->
<div cdkTabpanel [tab]="tablist.selected...">{{getContent(...)}}</div>
</div>
explaining the `inert` attribute
No description provided.