-
Notifications
You must be signed in to change notification settings - Fork 905
/
Copy pathtabs.ts
318 lines (282 loc) · 9.14 KB
/
tabs.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../divider/divider.js';
import {html, isServer, LitElement} from 'lit';
import {property, query, queryAssignedElements} from 'lit/decorators.js';
import {ANIMATE_INDICATOR, Tab} from './tab.js';
/**
* @fires change {Event} Fired when the selected tab changes. The target's
* `activeTabIndex` or `activeTab` provide information about the selection
* change. The change event is fired when a user interaction like a space/enter
* key or click cause a selection change. The tab selection based on these
* actions can be cancelled by calling preventDefault on the triggering
* `keydown` or `click` event. --bubbles
*
* @example
* // perform an action if a tab is clicked
* tabs.addEventListener('change', (event: Event) => {
* if (event.target.activeTabIndex === 2)
* takeAction();
* }
* });
*
* // prevent a click from triggering tab selection under some condition
* tabs.addEventListener('click', (event: Event) => {
* if (notReady)
* event.preventDefault();
* }
* });
*
*/
export class Tabs extends LitElement {
/**
* The tabs of this tab bar.
*/
@queryAssignedElements({flatten: true, selector: '[md-tab]'})
readonly tabs!: Tab[];
/**
* The currently selected tab, `null` only when there are no tab children.
*
* @export
*/
get activeTab() {
return this.tabs.find((tab) => tab.active) ?? null;
}
set activeTab(tab: Tab | null) {
// Ignore setting activeTab to null. As long as there are children, one tab
// must be selected.
if (tab) {
this.activateTab(tab);
}
}
/**
* The index of the currently selected tab.
*
* @export
*/
@property({type: Number, attribute: 'active-tab-index'})
get activeTabIndex() {
return this.tabs.findIndex((tab) => tab.active);
}
set activeTabIndex(index: number) {
const activateTabAtIndex = () => {
const tab = this.tabs[index];
// Ignore out-of-bound indices.
if (tab) {
this.activateTab(tab);
}
};
if (!this.slotElement) {
// This is needed to support setting the activeTabIndex via a lit property
// binding.
//
// ```ts
// html`
// <md-tabs .activeTabIndex=${1}>
// <md-tab>First</md-tab>
// <md-tab>Second</md-tab>
// </md-tabs>
// `;
// ```
//
// It's needed since lit's rendering lifecycle is asynchronous, and the
// `<slot>` element hasn't rendered, so `tabs` is empty.
this.updateComplete.then(activateTabAtIndex);
return;
}
activateTabAtIndex();
}
/**
* Whether or not to automatically select a tab when it is focused.
*/
@property({type: Boolean, attribute: 'auto-activate'}) autoActivate = false;
@query('.tabs') private readonly tabsScrollerElement!: HTMLElement | null;
@query('slot') private readonly slotElement!: HTMLSlotElement | null;
private get focusedTab() {
return this.tabs.find((tab) => tab.matches(':focus-within'));
}
private readonly internals =
// Cast needed for closure
(this as HTMLElement).attachInternals();
constructor() {
super();
if (!isServer) {
this.internals.role = 'tablist';
this.addEventListener('keydown', this.handleKeydown.bind(this));
this.addEventListener('keyup', this.handleKeyup.bind(this));
this.addEventListener('focusout', this.handleFocusout.bind(this));
}
}
/**
* Scrolls the toolbar, if overflowing, to the active tab, or the provided
* tab.
*
* @param tabToScrollTo The tab that should be scrolled to. Defaults to the
* active tab.
* @return A Promise that resolves after the tab has been scrolled to.
*/
async scrollToTab(tabToScrollTo?: Tab | null) {
await this.updateComplete;
const {tabs} = this;
tabToScrollTo ??= this.activeTab;
if (
!tabToScrollTo ||
!tabs.includes(tabToScrollTo) ||
!this.tabsScrollerElement
) {
return;
}
// wait for tabs to render.
for (const tab of this.tabs) {
await tab.updateComplete;
}
const offset = tabToScrollTo.offsetLeft;
const extent = tabToScrollTo.offsetWidth;
const scroll = this.scrollLeft;
const hostExtent = this.offsetWidth;
const scrollMargin = 48;
const min = offset - scrollMargin;
const max = offset + extent - hostExtent + scrollMargin;
const to = Math.min(min, Math.max(max, scroll));
// When a tab is focused, use 'auto' to use the CSS `scroll-behavior`. The
// default behavior is smooth scrolling. However, when there is not a tab
// focused on initialization, use 'instant' to immediately bring the focused
// tab into view.
const behavior: ScrollBehavior = !this.focusedTab ? 'instant' : 'auto';
this.tabsScrollerElement.scrollTo({behavior, top: 0, left: to});
}
protected override render() {
return html`
<div class="tabs">
<slot
@slotchange=${this.handleSlotChange}
@click=${this.handleTabClick}></slot>
</div>
<md-divider part="divider"></md-divider>
`;
}
private async handleTabClick(event: Event) {
const tab = event.target;
// Allow event to bubble
await 0;
if (event.defaultPrevented || !isTab(tab) || tab.active) {
return;
}
this.activateTab(tab);
}
private activateTab(activeTab: Tab) {
const {tabs} = this;
const previousTab = this.activeTab;
if (!tabs.includes(activeTab) || previousTab === activeTab) {
// Ignore setting activeTab to a tab element that is not a child.
return;
}
for (const tab of tabs) {
tab.active = tab === activeTab;
}
if (previousTab) {
// Don't dispatch a change event if activating a tab when no previous tabs
// were selected, such as when md-tabs auto-selects the first tab.
const defaultPrevented = !this.dispatchEvent(
new Event('change', {bubbles: true, cancelable: true}),
);
if (defaultPrevented) {
for (const tab of tabs) {
tab.active = tab === previousTab;
}
return;
}
activeTab[ANIMATE_INDICATOR](previousTab);
}
this.updateFocusableTab(activeTab);
this.scrollToTab(activeTab);
}
private updateFocusableTab(focusableTab: Tab) {
for (const tab of this.tabs) {
tab.tabIndex = tab === focusableTab ? 0 : -1;
}
}
// focus item on keydown and optionally select it
private async handleKeydown(event: KeyboardEvent) {
// Allow event to bubble.
await 0;
const isLeft = event.key === 'ArrowLeft';
const isRight = event.key === 'ArrowRight';
const isHome = event.key === 'Home';
const isEnd = event.key === 'End';
// Ignore non-navigation keys
if (event.defaultPrevented || (!isLeft && !isRight && !isHome && !isEnd)) {
return;
}
const {tabs} = this;
// Don't try to select another tab if there aren't any.
if (tabs.length < 2) {
return;
}
// Prevent default interactions, such as scrolling.
event.preventDefault();
let indexToFocus: number;
if (isHome || isEnd) {
indexToFocus = isHome ? 0 : tabs.length - 1;
} else {
// Check if moving forwards or backwards
const isRtl = getComputedStyle(this).direction === 'rtl';
const forwards = isRtl ? isLeft : isRight;
const {focusedTab} = this;
if (!focusedTab) {
// If there is not already a tab focused, select the first or last tab
// based on the direction we're traveling.
indexToFocus = forwards ? 0 : tabs.length - 1;
} else {
const focusedIndex = this.tabs.indexOf(focusedTab);
indexToFocus = forwards ? focusedIndex + 1 : focusedIndex - 1;
if (indexToFocus >= tabs.length) {
// Return to start if moving past the last item.
indexToFocus = 0;
} else if (indexToFocus < 0) {
// Go to end if moving before the first item.
indexToFocus = tabs.length - 1;
}
}
}
const tabToFocus = tabs[indexToFocus];
tabToFocus.focus();
if (this.autoActivate) {
this.activateTab(tabToFocus);
} else {
this.updateFocusableTab(tabToFocus);
}
}
// scroll to item on keyup.
private handleKeyup() {
this.scrollToTab(this.focusedTab ?? this.activeTab);
}
private handleFocusout() {
// restore focus to selected item when blurring the tab bar.
if (this.matches(':focus-within')) {
return;
}
const {activeTab} = this;
if (activeTab) {
this.updateFocusableTab(activeTab);
}
}
private handleSlotChange() {
const firstTab = this.tabs[0];
if (!this.activeTab && firstTab) {
// If the active tab was removed, auto-select the first one. There should
// always be a selected tab while the bar has children.
this.activateTab(firstTab);
}
// When children shift, ensure the active tab is visible. For example, if
// many children are added before the active tab, it'd be pushed off screen.
// This ensures it stays visible.
this.scrollToTab(this.activeTab);
}
}
function isTab(element: unknown): element is Tab {
return element instanceof HTMLElement && element.hasAttribute('md-tab');
}