Skip to content

Commit e6a2cd8

Browse files
feat: tab group priority navigation (#573)
Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
1 parent beb237a commit e6a2cd8

File tree

4 files changed

+323
-49
lines changed

4 files changed

+323
-49
lines changed

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"cSpell.words": ["combobox", "Umbraco"]
2+
"cSpell.words": ["combobox", "cssprop", "noopener", "noreferrer", "Umbraco"]
33
}

packages/uui-tabs/lib/uui-tab-group.element.ts

Lines changed: 231 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { defineElement } from '@umbraco-ui/uui-base/lib/registration';
22
import { css, html, LitElement } from 'lit';
3-
import { queryAssignedElements } from 'lit/decorators.js';
3+
import { property, query, queryAssignedElements } from 'lit/decorators.js';
4+
import { repeat } from 'lit/directives/repeat.js';
5+
6+
import type { UUIButtonElement } from '@umbraco-ui/uui-button/lib';
7+
import '@umbraco-ui/uui-button/lib/uui-button.element';
8+
import '@umbraco-ui/uui-popover-container/lib/uui-popover-container.element';
9+
import '@umbraco-ui/uui-symbol-more/lib/uui-symbol-more.element';
410

511
import { UUITabElement } from './uui-tab.element';
612

@@ -24,58 +30,263 @@ export class UUITabGroupElement extends LitElement {
2430
::slotted(*:not(:last-of-type)) {
2531
border-right: 1px solid var(--uui-tab-divider, none);
2632
}
33+
34+
.hidden-tab {
35+
width: 100%;
36+
}
37+
38+
#hidden-tabs-container {
39+
width: fit-content;
40+
display: flex;
41+
flex-direction: column;
42+
background: var(--uui-color-surface);
43+
border-radius: var(--uui-border-radius);
44+
box-shadow: var(--uui-shadow-depth-3);
45+
overflow: hidden;
46+
}
47+
:host([dropdown-direction='horizontal']) #hidden-tabs-container {
48+
flex-direction: row;
49+
}
50+
51+
#more-button {
52+
margin-left: auto;
53+
position: relative;
54+
}
55+
#more-button::before {
56+
content: '';
57+
position: absolute;
58+
bottom: 0;
59+
width: 100%;
60+
background-color: var(--uui-color-current);
61+
height: 0px;
62+
border-radius: 3px 3px 0 0;
63+
opacity: 0;
64+
transition: opacity ease-in 120ms, height ease-in 120ms;
65+
}
66+
#more-button.active-inside::before {
67+
opacity: 1;
68+
height: 4px;
69+
transition: opacity 120ms, height ease-out 120ms;
70+
}
2771
`,
2872
];
2973

74+
@query('#more-button')
75+
private _moreButtonElement!: UUIButtonElement;
76+
3077
@queryAssignedElements({
3178
flatten: true,
3279
selector: 'uui-tab, [uui-tab], [role=tab]',
3380
})
3481
private _slottedNodes?: HTMLElement[];
35-
private _tabElements: HTMLElement[] = [];
3682

37-
private _setTabArray() {
38-
this._tabElements = this._slottedNodes ? this._slottedNodes : [];
83+
/**
84+
* Set the flex direction of the content of the dropdown.
85+
* @type {string}
86+
* @attr
87+
* @default vertical
88+
*/
89+
@property({
90+
type: String,
91+
reflect: true,
92+
attribute: 'dropdown-content-direction',
93+
})
94+
dropdownContentDirection: 'vertical' | 'horizontal' = 'vertical';
95+
96+
#tabElements: HTMLElement[] = [];
97+
98+
#hiddenTabElements: UUITabElement[] = [];
99+
#hiddenTabElementsMap: Map<UUITabElement, UUITabElement> = new Map();
100+
101+
#visibilityBreakpoints: number[] = [];
102+
#oldBreakpoint = 0;
103+
104+
#resizeObserver: ResizeObserver = new ResizeObserver(
105+
this.#onResize.bind(this)
106+
);
107+
108+
connectedCallback() {
109+
super.connectedCallback();
110+
this.#resizeObserver.observe(this);
111+
if (!this.hasAttribute('role')) this.setAttribute('role', 'tablist');
112+
}
113+
114+
disconnectedCallback() {
115+
super.disconnectedCallback();
116+
this.#resizeObserver.unobserve(this);
39117
}
40118

41-
private _onSlotChange() {
42-
this._tabElements.forEach(el => {
43-
el.removeEventListener('click', this._onTabClicked);
119+
#onResize(entries: ResizeObserverEntry[]) {
120+
this.#updateCollapsibleTabs(entries[0].contentBoxSize[0].inlineSize);
121+
}
122+
123+
#onSlotChange() {
124+
this.#tabElements.forEach(el => {
125+
el.removeEventListener('click', this.#onTabClicked);
44126
});
45127

46-
this._setTabArray();
128+
this.#setTabArray();
47129

48-
this._tabElements.forEach(el => {
49-
el.addEventListener('click', this._onTabClicked);
130+
this.#tabElements.forEach(el => {
131+
el.addEventListener('click', this.#onTabClicked);
50132
});
51133
}
52134

53-
private _onTabClicked = (e: MouseEvent) => {
135+
#onTabClicked = (e: MouseEvent) => {
54136
const selectedElement = e.currentTarget as HTMLElement;
55-
if (this._elementIsTabLike(selectedElement)) {
137+
if (this.#isElementTabLike(selectedElement)) {
56138
selectedElement.active = true;
139+
const linkedElement = this.#hiddenTabElementsMap.get(selectedElement);
140+
141+
if (linkedElement) {
142+
linkedElement.active = true;
143+
}
57144

58-
const filtered = this._tabElements.filter(el => el !== selectedElement);
145+
// Reset all other tabs
146+
const filtered = [
147+
...this.#tabElements,
148+
...this.#hiddenTabElements,
149+
].filter(el => el !== selectedElement && el !== linkedElement);
59150

60151
filtered.forEach(el => {
61-
if (this._elementIsTabLike(el)) {
152+
if (this.#isElementTabLike(el)) {
62153
el.active = false;
63154
}
64155
});
156+
157+
// Check if there are any active tabs in the dropdown
158+
const hasActiveHidden = this.#hiddenTabElements.some(
159+
el => el.active && el !== linkedElement
160+
);
161+
162+
hasActiveHidden
163+
? this._moreButtonElement.classList.add('active-inside')
164+
: this._moreButtonElement.classList.remove('active-inside');
65165
}
66166
};
67167

68-
private _elementIsTabLike(el: any): el is UUITabElement {
69-
return el instanceof UUITabElement || 'active' in el;
168+
#updateCollapsibleTabs(containerWidth: number) {
169+
const buttonWidth = this._moreButtonElement.offsetWidth;
170+
171+
// Only update if the container is smaller than the last breakpoint
172+
if (
173+
this.#visibilityBreakpoints.slice(-1)[0] < containerWidth &&
174+
this.#hiddenTabElements.length === 0
175+
)
176+
return;
177+
178+
// Only update if the new breakpoint is different from the old one
179+
let newBreakpoint = Number.MAX_VALUE;
180+
181+
for (let i = this.#visibilityBreakpoints.length - 1; i > -1; i--) {
182+
const breakpoint = this.#visibilityBreakpoints[i];
183+
// Subtract the button width when we are not at the last breakpoint
184+
const containerWidthButtonWidth =
185+
containerWidth -
186+
(i !== this.#visibilityBreakpoints.length - 1 ? buttonWidth : 0);
187+
188+
if (breakpoint < containerWidthButtonWidth) {
189+
newBreakpoint = i;
190+
break;
191+
}
192+
}
193+
194+
if (newBreakpoint === this.#oldBreakpoint) return;
195+
this.#oldBreakpoint = newBreakpoint;
196+
197+
// Do the update
198+
// Reset the hidden tabs
199+
this.#hiddenTabElements.forEach(el => {
200+
el.removeEventListener('click', this.#onTabClicked);
201+
});
202+
this.#hiddenTabElements = [];
203+
this.#hiddenTabElementsMap.clear();
204+
205+
let hasActiveTabInDropdown = false;
206+
207+
for (let i = 0; i < this.#visibilityBreakpoints.length; i++) {
208+
const breakpoint = this.#visibilityBreakpoints[i];
209+
const tab = this.#tabElements[i] as UUITabElement;
210+
211+
// Subtract the button width when we are not at the last breakpoint
212+
const containerWidthButtonWidth =
213+
containerWidth -
214+
(i !== this.#visibilityBreakpoints.length - 1 ? buttonWidth : 0);
215+
216+
if (breakpoint < containerWidthButtonWidth) {
217+
tab.style.display = '';
218+
this._moreButtonElement.style.display = 'none';
219+
} else {
220+
// Make a proxy tab to put in the hidden tabs container and link it to the original tab
221+
const proxyTab = tab.cloneNode(true) as UUITabElement;
222+
proxyTab.addEventListener('click', this.#onTabClicked);
223+
proxyTab.classList.add('hidden-tab');
224+
proxyTab.style.display = '';
225+
proxyTab.orientation = this.dropdownContentDirection;
226+
227+
// Link the proxy tab to the original tab
228+
this.#hiddenTabElementsMap.set(proxyTab, tab);
229+
this.#hiddenTabElementsMap.set(tab, proxyTab);
230+
231+
this.#hiddenTabElements.push(proxyTab);
232+
233+
tab.style.display = 'none';
234+
this._moreButtonElement.style.display = '';
235+
if (tab.active) {
236+
hasActiveTabInDropdown = true;
237+
}
238+
}
239+
}
240+
241+
hasActiveTabInDropdown
242+
? this._moreButtonElement.classList.add('active-inside')
243+
: this._moreButtonElement.classList.remove('active-inside');
244+
245+
this.requestUpdate();
70246
}
71247

72-
connectedCallback() {
73-
super.connectedCallback();
74-
if (!this.hasAttribute('role')) this.setAttribute('role', 'tablist');
248+
#calculateBreakPoints() {
249+
// Whenever a tab is added or removed, we need to recalculate the breakpoints
250+
let childrenWidth = 0;
251+
252+
for (let i = 0; i < this.#tabElements.length; i++) {
253+
childrenWidth += this.#tabElements[i].offsetWidth;
254+
this.#visibilityBreakpoints[i] = childrenWidth;
255+
}
256+
257+
this.#updateCollapsibleTabs(this.offsetWidth);
258+
}
259+
260+
#setTabArray() {
261+
this.#tabElements = this._slottedNodes ? this._slottedNodes : [];
262+
this.#calculateBreakPoints();
263+
}
264+
265+
#isElementTabLike(el: any): el is UUITabElement {
266+
return el instanceof UUITabElement || 'active' in el;
75267
}
76268

77269
render() {
78-
return html` <slot @slotchange=${this._onSlotChange}></slot> `;
270+
return html`
271+
<slot @slotchange=${this.#onSlotChange}></slot>
272+
<uui-button
273+
popovertarget="popover-container"
274+
style="display: none"
275+
id="more-button"
276+
label="More"
277+
compact>
278+
<uui-symbol-more></uui-symbol-more>
279+
</uui-button>
280+
<uui-popover-container
281+
id="popover-container"
282+
popover
283+
margin="10"
284+
placement="bottom-end">
285+
<div id="hidden-tabs-container">
286+
${repeat(this.#hiddenTabElements, el => html`${el}`)}
287+
</div>
288+
</uui-popover-container>
289+
`;
79290
}
80291
}
81292

0 commit comments

Comments
 (0)