Skip to content

Commit dd5f3f0

Browse files
feat(tabs): improves support for dynamically adding/removing tabs
PiperOrigin-RevId: 532511300
1 parent 2c5e2b9 commit dd5f3f0

File tree

5 files changed

+136
-23
lines changed

5 files changed

+136
-23
lines changed

tabs/demo/stories.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import '@material/web/icon/icon.js';
8+
import '@material/web/iconbutton/standard-icon-button.js';
89
import '@material/web/tabs/tabs.js';
910

1011
import {MaterialStoryInit} from './material-collection.js';
@@ -322,6 +323,92 @@ const primaryAndSecondary: MaterialStoryInit<StoryKnobs> = {
322323
}
323324
};
324325

326+
const dynamic: MaterialStoryInit<StoryKnobs> = {
327+
name: 'Dynamic Tabs',
328+
styles,
329+
render(knobs) {
330+
const inlineIcon = knobs.inlineIcon;
331+
const vertical = knobs.vertical ? 'vertical' : '';
332+
const variant = `primary ${vertical}` as Variant;
333+
const classes = {vertical, scrolling: true};
334+
335+
function getTabs(e: Event) {
336+
return ((e.target! as Element).getRootNode() as ShadowRoot)
337+
.querySelector('md-tabs')!;
338+
}
339+
340+
function addTab(e: Event) {
341+
const tabs = getTabs(e);
342+
const count = tabs.childElementCount;
343+
const tab = document.createElement('md-tab');
344+
tab.textContent = `Tab ${count + 1}`;
345+
if (tabs.selectedItem !== undefined) {
346+
tabs.selectedItem.after(tab);
347+
tabs.selected++;
348+
} else {
349+
tabs.append(tab);
350+
tabs.selected = count;
351+
}
352+
}
353+
function removeTab(e: Event) {
354+
const tabs = getTabs(e);
355+
if (tabs.selectedItem === undefined) {
356+
return;
357+
}
358+
tabs.selectedItem?.remove();
359+
const count = tabs.childElementCount;
360+
tabs.selected = Math.min(count - 1, tabs.selected);
361+
}
362+
363+
function moveTabTowardsEnd(e: Event) {
364+
const tabs = getTabs(e);
365+
const next = tabs.selectedItem?.nextElementSibling;
366+
if (next) {
367+
next.after(tabs.selectedItem);
368+
tabs.selected++;
369+
}
370+
}
371+
372+
function moveTabTowardsStart(e: Event) {
373+
const tabs = getTabs(e);
374+
const previous = tabs.selectedItem?.previousElementSibling;
375+
if (previous) {
376+
previous.before(tabs.selectedItem);
377+
tabs.selected--;
378+
}
379+
}
380+
381+
return html`
382+
<div>
383+
<md-standard-icon-button @click=${
384+
addTab}><md-icon>add</md-icon></md-standard-icon-button>
385+
<md-standard-icon-button @click=${
386+
removeTab}><md-icon>remove</md-icon></md-standard-icon-button>
387+
<md-standard-icon-button @click=${
388+
moveTabTowardsStart}><md-icon>chevron_left</md-icon></md-standard-icon-button>
389+
<md-standard-icon-button @click=${
390+
moveTabTowardsEnd}><md-icon>chevron_right</md-icon></md-standard-icon-button>
391+
</div>
392+
<md-tabs
393+
class=${classMap(classes)}
394+
.variant=${variant}
395+
.selected=${knobs.selected}
396+
.disabled=${knobs.disabled}
397+
.selectOnFocus=${knobs.selectOnFocus}
398+
>
399+
<md-tab .inlineIcon=${inlineIcon}>
400+
Tab 1
401+
</md-tab>
402+
<md-tab .inlineIcon=${inlineIcon}>
403+
Tab 2
404+
</md-tab>
405+
<md-tab .inlineIcon=${inlineIcon}>
406+
Tab 3
407+
</md-tab>
408+
</md-tabs>`;
409+
}
410+
};
411+
325412
function getTabContentGenerator(knobs: StoryKnobs) {
326413
const contentKnob = knobs.content;
327414
const useIcon = contentKnob !== 'label';
@@ -333,4 +420,4 @@ function getTabContentGenerator(knobs: StoryKnobs) {
333420

334421
/** Tabs stories. */
335422
export const stories =
336-
[primary, secondary, scrolling, custom, primaryAndSecondary];
423+
[primary, secondary, scrolling, custom, primaryAndSecondary, dynamic];

tabs/lib/_tab.scss

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,13 +226,10 @@
226226
color: var(--_active-pressed-icon-color);
227227
}
228228

229-
// TODO (b/261201556) implement disabled and high contrast mode
230-
// styling in beta version.
231229
// disabled state
232230
:host([disabled]) {
233231
cursor: default;
234232
pointer-events: none;
235-
// TODO (b/261201556) implement disabled styling in beta version.
236233
opacity: 0.38;
237234
}
238235

tabs/lib/_tabs.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
box-sizing: border-box;
1111
display: flex;
1212
justify-content: space-between;
13-
align-items: center;
13+
align-items: end;
1414
overflow: auto;
1515
scroll-behavior: smooth;
1616
scrollbar-width: none;

tabs/lib/tabs.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import {html, isServer, LitElement, PropertyValues} from 'lit';
8-
import {property, query} from 'lit/decorators.js';
8+
import {property, queryAssignedElements, state} from 'lit/decorators.js';
99

1010
import {Tab, Variant} from './tab.js';
1111

@@ -67,15 +67,16 @@ export class Tabs extends LitElement {
6767
*/
6868
@property({type: Boolean}) selectOnFocus = false;
6969

70-
@query('slot') private readonly itemsSlot?: HTMLSlotElement;
71-
7270
private previousSelected = -1;
7371
private orientation = 'horizontal';
7472
private readonly scrollMargin = 48;
7573

76-
private get items() {
77-
return this.itemsSlot?.assignedElements({flatten: true}) as Tab[] ?? [];
78-
}
74+
@queryAssignedElements({selector: 'md-tab', flatten: true})
75+
private readonly items!: Tab[];
76+
77+
// this tracks if items have changed, which triggers rendering so they can
78+
// be kept in sync
79+
@state() private itemsDirty = false;
7980

8081
private readonly selectedAttribute = `selected`;
8182

@@ -229,16 +230,21 @@ export class Tabs extends LitElement {
229230
this.orientation =
230231
this.variant.includes('vertical') ? 'vertical' : 'horizontal';
231232
}
233+
if (this.itemsDirty) {
234+
this.itemsDirty = false;
235+
this.previousSelected = -1;
236+
}
232237
}
233238

234239
protected override async updated(changed: PropertyValues) {
235240
const itemsOrVariantChanged =
236-
changed.has('items') || changed.has('variant');
237-
// sync variant with items.
241+
changed.has('itemsDirty') || changed.has('variant');
242+
// sync state with items.
238243
if (itemsOrVariantChanged || changed.has('disabled')) {
239-
this.items.forEach(i => {
240-
i.variant = this.variant;
241-
i.disabled = this.disabled;
244+
this.items.forEach((item, i) => {
245+
item.selected = this.selected === i;
246+
item.variant = this.variant;
247+
item.disabled = this.disabled;
242248
});
243249
}
244250
if (itemsOrVariantChanged || changed.has('selected')) {
@@ -253,15 +259,15 @@ export class Tabs extends LitElement {
253259
}
254260
}
255261

256-
private updateFocusableItem(item: HTMLElement|null) {
262+
private updateFocusableItem(focusableItem: HTMLElement|null) {
257263
const tabIndex = 'tabindex';
258-
this.items.forEach(e => {
259-
if (e === item) {
260-
e.removeAttribute(tabIndex);
264+
for (const item of this.items) {
265+
if (item === focusableItem) {
266+
item.removeAttribute(tabIndex);
261267
} else {
262-
e.setAttribute(tabIndex, '-1');
268+
item.setAttribute(tabIndex, '-1');
263269
}
264-
});
270+
}
265271
}
266272

267273
protected override render() {
@@ -289,7 +295,7 @@ export class Tabs extends LitElement {
289295
}
290296

291297
private handleSlotChange(e: Event) {
292-
this.requestUpdate();
298+
this.itemsDirty = true;
293299
}
294300

295301
private async itemsUpdateComplete() {

tabs/tabs_test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,28 @@ describe('<md-tabs>', () => {
8181
expect(harness.element.previousSelectedItem)
8282
.toBe(harness.harnessedItems[1].element);
8383
});
84+
85+
it('maintains selection when tabs are mutated', async () => {
86+
const {harness} = await setupTest({selected: 1});
87+
expect(harness.element.selectedItem.textContent).toBe('B');
88+
const tab = document.createElement('md-tab');
89+
tab.textContent = 'tab';
90+
// add before selected
91+
harness.element.prepend(tab);
92+
await env.waitForStability();
93+
expect(harness.element.selectedItem.textContent).toBe('A');
94+
// move after selected
95+
harness.element.selectedItem.after(tab);
96+
await env.waitForStability();
97+
expect(harness.element.selectedItem.textContent).toBe('tab');
98+
// move before selected
99+
harness.element.prepend(tab);
100+
await env.waitForStability();
101+
expect(harness.element.selectedItem.textContent).toBe('A');
102+
// remove
103+
tab.remove();
104+
await env.waitForStability();
105+
expect(harness.element.selectedItem.textContent).toBe('B');
106+
});
84107
});
85108
});

0 commit comments

Comments
 (0)