Skip to content

Commit fd5f0bb

Browse files
committed
refactor(aria/tabs): move selection of tabs to each tab
1 parent f237ad8 commit fd5f0bb

File tree

8 files changed

+89
-142
lines changed

8 files changed

+89
-142
lines changed

goldens/aria/private/index.api.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -665,13 +665,13 @@ export type SignalLike<T> = () => T;
665665

666666
// @public
667667
export interface TabInputs extends Omit<ListNavigationItem, 'index'>, Omit<ExpansionItem, 'expandable' | 'expanded'> {
668+
readonly selected: WritableSignalLike<boolean>;
668669
tabList: SignalLike<TabListPattern>;
669670
tabPanel: SignalLike<TabPanelPattern | undefined>;
670671
}
671672

672673
// @public
673674
export interface TabListInputs extends Omit<ListNavigationInputs<TabPattern>, 'multi'>, Omit<ListExpansionInputs, 'multiExpandable' | 'items'> {
674-
selectedTab: WritableSignalLike<TabPattern | undefined>;
675675
selectionMode: SignalLike<'follow' | 'explicit'>;
676676
}
677677

@@ -695,7 +695,6 @@ export class TabListPattern {
695695
readonly orientation: SignalLike<'vertical' | 'horizontal'>;
696696
readonly pointerdown: SignalLike<PointerEventManager<PointerEvent>>;
697697
readonly prevKey: SignalLike<"ArrowUp" | "ArrowRight" | "ArrowLeft">;
698-
readonly selectedTab: WritableSignalLike<TabPattern | undefined>;
699698
setDefaultState(): void;
700699
readonly tabIndex: SignalLike<0 | -1>;
701700
}
@@ -726,13 +725,11 @@ export class TabPattern {
726725
readonly disabled: SignalLike<boolean>;
727726
readonly element: SignalLike<HTMLElement>;
728727
readonly expandable: SignalLike<boolean>;
729-
// (undocumented)
730728
readonly expanded: WritableSignalLike<boolean>;
731729
readonly id: SignalLike<string>;
732730
// (undocumented)
733731
readonly inputs: TabInputs;
734732
open(): boolean;
735-
readonly selected: SignalLike<boolean>;
736733
readonly tabIndex: SignalLike<0 | -1>;
737734
}
738735

goldens/aria/tabs/index.api.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
```ts
66

77
import { AfterViewInit } from '@angular/core';
8+
import * as _angular_aria_private_public_api from '@angular/aria/private/public-api';
89
import * as _angular_cdk_bidi from '@angular/cdk/bidi';
910
import * as _angular_core from '@angular/core';
1011
import { OnDestroy } from '@angular/core';
@@ -21,9 +22,9 @@ export class Tab implements AfterViewInit {
2122
open(): void;
2223
readonly panel: _angular_core.InputSignal<TabPanel>;
2324
readonly _pattern: TabPattern;
24-
readonly selected: _angular_core.Signal<boolean>;
25+
readonly selected: _angular_core.ModelSignal<boolean>;
2526
// (undocumented)
26-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Tab, "[ngTab]", ["ngTab"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "panel": { "alias": "panel"; "required": true; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
27+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Tab, "[ngTab]", ["ngTab"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "panel": { "alias": "panel"; "required": true; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "selected": { "alias": "selected"; "required": false; "isSignal": true; }; }, { "selected": "selectedChange"; }, never, never, true, never>;
2728
// (undocumented)
2829
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Tab, never>;
2930
}
@@ -38,22 +39,20 @@ export class TabContent {
3839

3940
// @public
4041
export class TabList implements AfterViewInit {
41-
constructor();
4242
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
4343
readonly element: HTMLElement;
4444
readonly focusMode: _angular_core.InputSignal<"roving" | "activedescendant">;
4545
// (undocumented)
4646
ngAfterViewInit(): void;
4747
readonly orientation: _angular_core.InputSignal<"vertical" | "horizontal">;
4848
readonly _pattern: TabListPattern;
49-
readonly selectedTabIndex: _angular_core.ModelSignal<number>;
5049
readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">;
5150
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
52-
readonly _tabPatterns: _angular_core.Signal<TabPattern[]>;
51+
readonly _tabPatterns: _angular_core.Signal<_angular_aria_private_public_api.TabPattern[]>;
5352
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
5453
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
5554
// (undocumented)
56-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<TabList, "[ngTabList]", ["ngTabList"], { "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "selectedTabIndex": { "alias": "selectedTabIndex"; "required": false; "isSignal": true; }; }, { "selectedTabIndex": "selectedTabIndexChange"; }, ["_tabs"], never, true, never>;
55+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<TabList, "[ngTabList]", ["ngTabList"], { "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; }, {}, ["_tabs"], never, true, never>;
5756
// (undocumented)
5857
static ɵfac: _angular_core.ɵɵFactoryDeclaration<TabList, never>;
5958
}

src/aria/private/tabs/tabs.spec.ts

Lines changed: 28 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ describe('Tabs Pattern', () => {
6565
softDisabled: signal(true),
6666
items: signal([]),
6767
element: signal(document.createElement('div')),
68-
selectedTab: signal(undefined),
6968
};
7069
tabListPattern = new TabListPattern(tabListInputs);
7170

@@ -77,20 +76,23 @@ describe('Tabs Pattern', () => {
7776
id: signal('tab-1-id'),
7877
element: signal(createTabElement()),
7978
disabled: signal(false),
79+
selected: signal(false),
8080
},
8181
{
8282
tabList: signal(tabListPattern),
8383
tabPanel: signal(undefined),
8484
id: signal('tab-2-id'),
8585
element: signal(createTabElement()),
8686
disabled: signal(false),
87+
selected: signal(false),
8788
},
8889
{
8990
tabList: signal(tabListPattern),
9091
tabPanel: signal(undefined),
9192
id: signal('tab-3-id'),
9293
element: signal(createTabElement()),
9394
disabled: signal(false),
95+
selected: signal(false),
9496
},
9597
];
9698
tabPatterns = [
@@ -133,23 +135,15 @@ describe('Tabs Pattern', () => {
133135

134136
describe('TabListPattern', () => {
135137
describe('#open', () => {
136-
it('should open a tab with value', () => {
137-
expect(tabListPattern.selectedTab()).toBeUndefined();
138-
tabListPattern.open(tabPatterns[0]);
139-
expect(tabListPattern.selectedTab()!).toBe(tabPatterns[0]);
140-
});
141-
142138
it('should open a tab with tab pattern instance', () => {
143-
expect(tabListPattern.selectedTab()).toBeUndefined();
144-
tabListPattern.open(tabPatterns[0]);
145-
expect(tabListPattern.selectedTab()).toBe(tabPatterns[0]);
139+
tabListPattern.open(tabPatterns[1]);
140+
expect(tabPatterns[1].expanded()).toBeTrue();
146141
});
147142

148143
it('should open the active tab', () => {
149-
expect(tabListPattern.selectedTab()).toBeUndefined();
150144
expect(tabListPattern.activeTab()).toBe(tabPatterns[0]);
151145
tabListPattern.open();
152-
expect(tabListPattern.selectedTab()).toBe(tabPatterns[0]);
146+
expect(tabPatterns[0].expanded()).toBeTrue();
153147
});
154148
});
155149

@@ -172,22 +166,21 @@ describe('Tabs Pattern', () => {
172166
it('should set activeIndex to the first focusable tab if no tabs are selected', () => {
173167
tabListInputs.softDisabled.set(false);
174168
tabListInputs.activeItem.set(tabPatterns[2]);
175-
tabListPattern.selectedTab.set(undefined);
176169
tabInputs[0].disabled.set(true);
177170
tabListPattern.setDefaultState();
178171
expect(tabListInputs.activeItem()).toBe(tabPatterns[1]);
179172
});
180173

181174
it('should set activeIndex to the first focusable and selected tab', () => {
182175
tabListInputs.activeItem.set(tabPatterns[0]);
183-
tabListPattern.selectedTab.set(tabPatterns[2]);
176+
tabPatterns[2].expanded.set(true);
184177
tabListPattern.setDefaultState();
185178
expect(tabListInputs.activeItem()).toBe(tabPatterns[2]);
186179
});
187180

188181
it('should set activeIndex to the first focusable tab when the selected tab is not focusable', () => {
189182
tabListInputs.softDisabled.set(false);
190-
tabListPattern.selectedTab.set(tabPatterns[1]);
183+
tabPatterns[1].expanded.set(true);
191184
tabInputs[1].disabled.set(true);
192185
tabListPattern.setDefaultState();
193186
expect(tabListInputs.activeItem()).toBe(tabPatterns[0]);
@@ -221,37 +214,37 @@ describe('Tabs Pattern', () => {
221214

222215
it('selects a tab by focus if `selectionMode` is "follow".', () => {
223216
tabListPattern.onKeydown(space());
224-
expect(tabPatterns[0].selected()).toBeTrue();
225-
expect(tabPatterns[1].selected()).toBeFalse();
217+
expect(tabPatterns[0].expanded()).toBeTrue();
218+
expect(tabPatterns[1].expanded()).toBeFalse();
226219
tabListPattern.onKeydown(right());
227-
expect(tabPatterns[0].selected()).toBeFalse();
228-
expect(tabPatterns[1].selected()).toBeTrue();
220+
expect(tabPatterns[0].expanded()).toBeFalse();
221+
expect(tabPatterns[1].expanded()).toBeTrue();
229222
});
230223

231224
it('selects a tab by enter key if `selectionMode` is "explicit".', () => {
232225
tabListInputs.selectionMode.set('explicit');
233226
tabListPattern.onKeydown(space());
234-
expect(tabPatterns[0].selected()).toBeTrue();
235-
expect(tabPatterns[1].selected()).toBeFalse();
227+
expect(tabPatterns[0].expanded()).toBeTrue();
228+
expect(tabPatterns[1].expanded()).toBeFalse();
236229
tabListPattern.onKeydown(right());
237-
expect(tabPatterns[0].selected()).toBeTrue();
238-
expect(tabPatterns[1].selected()).toBeFalse();
230+
expect(tabPatterns[0].expanded()).toBeTrue();
231+
expect(tabPatterns[1].expanded()).toBeFalse();
239232
tabListPattern.onKeydown(enter());
240-
expect(tabPatterns[0].selected()).toBeFalse();
241-
expect(tabPatterns[1].selected()).toBeTrue();
233+
expect(tabPatterns[0].expanded()).toBeFalse();
234+
expect(tabPatterns[1].expanded()).toBeTrue();
242235
});
243236

244237
it('selects a tab by space key if `selectionMode` is "explicit".', () => {
245238
tabListInputs.selectionMode.set('explicit');
246239
tabListPattern.onKeydown(space());
247-
expect(tabPatterns[0].selected()).toBeTrue();
248-
expect(tabPatterns[1].selected()).toBeFalse();
240+
expect(tabPatterns[0].expanded()).toBeTrue();
241+
expect(tabPatterns[1].expanded()).toBeFalse();
249242
tabListPattern.onKeydown(right());
250-
expect(tabPatterns[0].selected()).toBeTrue();
251-
expect(tabPatterns[1].selected()).toBeFalse();
243+
expect(tabPatterns[0].expanded()).toBeTrue();
244+
expect(tabPatterns[1].expanded()).toBeFalse();
252245
tabListPattern.onKeydown(space());
253-
expect(tabPatterns[0].selected()).toBeFalse();
254-
expect(tabPatterns[1].selected()).toBeTrue();
246+
expect(tabPatterns[0].expanded()).toBeFalse();
247+
expect(tabPatterns[1].expanded()).toBeTrue();
255248
});
256249

257250
it('uses left key to navigate to the previous tab when `orientation` is set to "horizontal".', () => {
@@ -347,22 +340,21 @@ describe('Tabs Pattern', () => {
347340

348341
describe('#open', () => {
349342
it('should open the current tab', () => {
350-
expect(tabListPattern.selectedTab()).toBeUndefined();
351-
tabPatterns[0].open();
352-
expect(tabListPattern.selectedTab()).toBe(tabPatterns[0]);
343+
tabPatterns[1].open();
344+
expect(tabPatterns[1].expanded()).toBeTrue();
353345
});
354346
});
355347
});
356348

357349
describe('TabPanelPattern', () => {
358350
it('should set a tabpanel to be not hidden if a tab is opened', () => {
359351
tabPatterns[0].open();
360-
expect(tabPatterns[0].selected()).toBeTrue();
352+
expect(tabPatterns[0].expanded()).toBeTrue();
361353
expect(tabPanelPatterns[0].hidden()).toBeFalse();
362354
});
363355

364356
it('sets a tabpanel to be hidden if a tab is not opened', () => {
365-
expect(tabPatterns[1].selected()).toBeFalse();
357+
expect(tabPatterns[1].expanded()).toBeFalse();
366358
expect(tabPanelPatterns[1].hidden()).toBeTrue();
367359
});
368360

src/aria/private/tabs/tabs.ts

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,7 @@
88

99
import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager';
1010
import {ExpansionItem, ListExpansion, ListExpansionInputs} from '../behaviors/expansion/expansion';
11-
import {
12-
SignalLike,
13-
WritableSignalLike,
14-
computed,
15-
linkedSignal,
16-
} from '../behaviors/signal-like/signal-like';
11+
import {SignalLike, WritableSignalLike, computed} from '../behaviors/signal-like/signal-like';
1712
import {LabelControl, LabelControlOptionalInputs} from '../behaviors/label/label';
1813
import {ListFocus} from '../behaviors/list-focus/list-focus';
1914
import {
@@ -30,6 +25,9 @@ export interface TabInputs
3025

3126
/** The remote tabpanel controlled by the tab. */
3227
tabPanel: SignalLike<TabPanelPattern | undefined>;
28+
29+
/** Whether the tab is selected. */
30+
readonly selected: WritableSignalLike<boolean>;
3331
}
3432

3533
/** A tab in a tablist. */
@@ -46,22 +44,12 @@ export class TabPattern {
4644
/** Whether this tab has expandable panel. */
4745
readonly expandable: SignalLike<boolean> = () => true;
4846

49-
/*
50-
* Whether the tab panel is expanded.
51-
* Primarily controlled by the behavior, which will read/write this value.
52-
* The consumer of this pattern will instead only use the selectedTab input.
53-
* The pattern will be responsible for synchronizing their state.
54-
*/
55-
readonly expanded: WritableSignalLike<boolean> = linkedSignal(
56-
() => this.inputs.tabList().selectedTab() === this,
57-
);
47+
/** Whether the tab panel is expanded. */
48+
readonly expanded: WritableSignalLike<boolean>; // set from inputs (selected)
5849

5950
/** Whether the tab is active. */
6051
readonly active = computed(() => this.inputs.tabList().inputs.activeItem() === this);
6152

62-
/** Whether the tab is selected. */
63-
readonly selected = computed(() => this.inputs.tabList().selectedTab() === this);
64-
6553
/** The tab index of the tab. */
6654
readonly tabIndex = computed(() => this.inputs.tabList().focusBehavior.getItemTabIndex(this));
6755

@@ -71,6 +59,7 @@ export class TabPattern {
7159
constructor(readonly inputs: TabInputs) {
7260
this.id = inputs.id;
7361
this.disabled = inputs.disabled;
62+
this.expanded = inputs.selected;
7463
}
7564

7665
/** Opens the tab. */
@@ -126,9 +115,6 @@ export interface TabListInputs
126115
Omit<ListExpansionInputs, 'multiExpandable' | 'items'> {
127116
/** The selection strategy used by the tablist. */
128117
selectionMode: SignalLike<'follow' | 'explicit'>;
129-
130-
/** The currently selected tab. */
131-
selectedTab: WritableSignalLike<TabPattern | undefined>;
132118
}
133119

134120
/** Controls the state of a tablist. */
@@ -146,7 +132,6 @@ export class TabListPattern {
146132
readonly activeTab: SignalLike<TabPattern | undefined>; // set from inputs
147133

148134
/** The currently selected tab. */
149-
readonly selectedTab: WritableSignalLike<TabPattern | undefined>; // set from inputs
150135

151136
/** Whether the tablist is vertically or horizontally oriented. */
152137
readonly orientation: SignalLike<'vertical' | 'horizontal'>; // set from inputs
@@ -206,7 +191,6 @@ export class TabListPattern {
206191
});
207192

208193
constructor(readonly inputs: TabListInputs) {
209-
this.selectedTab = inputs.selectedTab;
210194
this.activeTab = inputs.activeItem;
211195
this.orientation = inputs.orientation;
212196
this.disabled = inputs.disabled;
@@ -242,14 +226,22 @@ export class TabListPattern {
242226
firstItem = item;
243227
}
244228

245-
if (item.selected()) {
229+
if (item.expanded()) {
246230
this.inputs.activeItem.set(item);
247231
return;
248232
}
249233
}
250234
if (firstItem !== undefined) {
251235
this.inputs.activeItem.set(firstItem);
252236
}
237+
238+
const selectedTabs = this.inputs.items().filter(tab => tab.expanded());
239+
if (selectedTabs.length == 0) {
240+
firstItem?.expanded.set(true);
241+
} else if (selectedTabs.length > 1) {
242+
// If multiple tabs are selected, only the first one should be expanded.
243+
selectedTabs.slice(1).forEach(tab => tab.expanded.set(false));
244+
}
253245
}
254246

255247
/** Handles keydown events for the tablist. */
@@ -273,12 +265,7 @@ export class TabListPattern {
273265

274266
if (tab === undefined) return false;
275267

276-
const success = this.expansionBehavior.open(tab);
277-
if (success) {
278-
this.selectedTab.set(tab);
279-
}
280-
281-
return success;
268+
return this.expansionBehavior.open(tab);
282269
}
283270

284271
/** Executes a navigation operation and expand the active tab if needed. */

0 commit comments

Comments
 (0)